From 78e02bde026f612531ed218b475c57e139643d5d Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 16:59:41 +0100 Subject: [PATCH 001/138] parser: count tree-sitter parses Edit paths parse the same bytes more than once (the pre-write gate, the indexer's extraction, syntax health), and nothing measures how many parses a request actually costs. Count every source ParseFile hands to tree-sitter and expose the total as ParseCount, so a test can pin the parse budget of a code path by comparing two readings. --- internal/parser/treesitter.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/parser/treesitter.go b/internal/parser/treesitter.go index 81112ace6..fdc45b576 100644 --- a/internal/parser/treesitter.go +++ b/internal/parser/treesitter.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" ts "github.com/tree-sitter/go-tree-sitter" @@ -106,6 +107,14 @@ func LooksBinary(src []byte) bool { return bytes.IndexByte(src, 0) >= 0 } +// parseCount counts the sources ParseFile handed to tree-sitter. +var parseCount atomic.Uint64 + +// ParseCount reports how many sources ParseFile has handed to tree-sitter +// since the process started. Callers compare two readings to pin how many +// parses a code path costs. +func ParseCount() uint64 { return parseCount.Load() } + // ParseFile parses source bytes with the given language and returns the tree. // The caller must call tree.Close() when done. func ParseFile(src []byte, lang *sitter.Language) (*sitter.Tree, error) { @@ -144,6 +153,7 @@ func ParseFile(src []byte, lang *sitter.Language) (*sitter.Tree, error) { ctx, cancel := context.WithTimeout(context.Background(), parseTimeout) defer cancel() + parseCount.Add(1) tree, err := parser.ParseCtx(ctx, nil, src) if err != nil { return nil, fmt.Errorf("tree-sitter parse: %w", err) From d5bbf8f5ceacc2d545c8d545be44d03ea8bdd415 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:04:16 +0100 Subject: [PATCH 002/138] mcp: parse an edit's prior bytes only when the candidate has errors The pre-write parse gate parsed the candidate and then always parsed the file's prior content to compare error counts, so every clean edit paid for two parses on the ack path. A candidate with no parse errors cannot be a regression whatever the prior bytes held, so the gate now parses them only when the candidate has errors to compare against: a clean edit costs one parse and a broken one still costs two. One response changes: an edit that fixes a broken file no longer carries a parse_gate block, since the prior error count is no longer computed for it. parse_gate still reports every edit whose candidate has errors. Refs #1012 --- internal/mcp/parse_gate.go | 10 ++++++---- internal/mcp/parse_gate_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/internal/mcp/parse_gate.go b/internal/mcp/parse_gate.go index 7f1a7314b..fdf3d2d24 100644 --- a/internal/mcp/parse_gate.go +++ b/internal/mcp/parse_gate.go @@ -104,7 +104,7 @@ type parseGateResult struct { Checked bool // the language was parseable and the gate ran Blocked bool // newContent is a regression over oldContent BecameBinary bool // the edit turns a text file into NUL-bearing binary content - OldErrors int // parse errors in the pre-edit content + OldErrors int // parse errors in the pre-edit content; counted only when NewErrors > 0 NewErrors int // parse errors in the candidate content Language string // gate language (may be set even when Checked is false) } @@ -152,8 +152,10 @@ func checkParseGate(path string, oldContent, newContent []byte) parseGateResult if !newOK { return parseGateResult{Language: lang} } + // A clean candidate cannot regress, so the prior bytes are parsed only + // when there are errors to compare against: a clean edit costs one parse. oldErrs := 0 - if len(oldContent) > 0 { + if newErrs > 0 && len(oldContent) > 0 { if e, ok := parseErrorCount(lang, oldContent); ok { oldErrs = e } @@ -186,8 +188,8 @@ func parseGateInfo(r parseGateResult, allowed bool) map[string]any { if !r.Checked { return nil } - if r.OldErrors == r.NewErrors && r.NewErrors == 0 && !r.Blocked { - return nil // nothing to report — a clean file stayed clean + if r.NewErrors == 0 && !r.Blocked { + return nil // nothing to report — the candidate is clean } m := map[string]any{ "language": r.Language, diff --git a/internal/mcp/parse_gate_test.go b/internal/mcp/parse_gate_test.go index ff6d765c5..a174f1f9c 100644 --- a/internal/mcp/parse_gate_test.go +++ b/internal/mcp/parse_gate_test.go @@ -1,8 +1,15 @@ package mcp import ( + "context" + "path/filepath" "strings" "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/parser" ) func TestParseGateLanguage(t *testing.T) { @@ -157,3 +164,31 @@ func TestParseGateInfo(t *testing.T) { t.Errorf("binary gate info = %v, want blocked:true became_binary:true", info) } } + +// TestEditParsesOncePerCleanEdit pins what the gate costs on the edit ack +// path: a candidate with no parse errors is parsed once, and the prior bytes +// are parsed only when there are candidate errors to compare against. The +// watcher admits the reindex, so the counted parses are the gate's own. +func TestEditParsesOncePerCleanEdit(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + mainGo := filepath.Join(dir, "main.go") + + edit := func(oldString, newString string) (uint64, *mcplib.CallToolResult) { + t.Helper() + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{"path": mainGo, "old_string": oldString, "new_string": newString} + before := parser.ParseCount() + res, err := srv.handleEditFile(context.Background(), req) + require.NoError(t, err) + return parser.ParseCount() - before, res + } + + parses, res := edit("func helper() {}", "func helper() { _ = 1 }") + require.False(t, res.IsError, "clean edit refused: %v", res.Content) + require.EqualValues(t, 1, parses, "a clean edit parses only the candidate") + + parses, res = edit("func helper() { _ = 1 }", "func helper() { _ = }") + require.True(t, res.IsError, "the gate must refuse a syntax regression") + require.EqualValues(t, 2, parses, "a broken candidate is compared against the prior bytes") +} From 7d466c618df60d4c4ffadcc0273ce22f928e0c6b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:07:43 +0100 Subject: [PATCH 003/138] indexer: report whether bytes match a file's recorded content Readers and editors that hold a file's bytes need to know whether the spans of the file's stored nodes describe those bytes, or whether an edit has moved them since the last index. The watcher already answers that question for its own replay check, but only after re-reading the file itself, so the bytes it vouches for are not the caller's. IndexedContentMatches compares the caller's bytes with the persisted content identity (size and BLAKE3 over the transformed bytes) and answers false whenever the backend cannot tell. Refs #1014 --- internal/indexer/content_match.go | 32 ++++++++++++++++++++++++++ internal/indexer/content_match_test.go | 30 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/indexer/content_match.go create mode 100644 internal/indexer/content_match_test.go diff --git a/internal/indexer/content_match.go b/internal/indexer/content_match.go new file mode 100644 index 000000000..729ec7152 --- /dev/null +++ b/internal/indexer/content_match.go @@ -0,0 +1,32 @@ +package indexer + +import ( + "path/filepath" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/pathkey" +) + +// IndexedContentMatches reports whether src, bytes the caller already read +// for the repo-relative relPath, are the content the index last recorded for +// that file, so the spans of the file's stored nodes describe src exactly. It +// compares the persisted content identity (size and BLAKE3 over the +// transformed bytes) and answers false whenever the backend cannot tell. +func (idx *Indexer) IndexedContentMatches(relPath string, src []byte) bool { + reader, ok := idx.graph.(graph.FileMetaPathReader) + if !ok { + return false + } + relPath = pathkey.Normalize(filepath.ToSlash(relPath)) + graphPath := idx.prefixPath(relPath) + rows, err := reader.FileMetasByPaths(idx.repoPrefix, []string{graphPath}) + if err != nil { + return false + } + row, ok := rows[graphPath] + if !ok || row.ContentHash == "" { + return false + } + src = idx.transforms.run(relPath, src) + return len(src) == row.Size && contentHashForSource(src) == row.ContentHash +} diff --git a/internal/indexer/content_match_test.go b/internal/indexer/content_match_test.go new file mode 100644 index 000000000..a0c01ff00 --- /dev/null +++ b/internal/indexer/content_match_test.go @@ -0,0 +1,30 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestIndexedContentMatches(t *testing.T) { + dir := t.TempDir() + plain := []byte("package main\n\nfunc A() {}\n") + // The recorded identity is over transformed bytes, so a raw read that + // still carries the BOM the index stripped must match too. + bom := append([]byte{0xef, 0xbb, 0xbf}, []byte("package main\n\nfunc B() {}\n")...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), plain, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.go"), bom, 0o644)) + idx := newExtractSourceTestIndexer(t, config.Default().Index) + _, err := idx.Index(dir) + require.NoError(t, err) + + require.True(t, idx.IndexedContentMatches("a.go", plain)) + require.True(t, idx.IndexedContentMatches("b.go", bom)) + require.False(t, idx.IndexedContentMatches("a.go", []byte("package main\n\n\nfunc A() {}\n")), + "bytes the index has not seen must not vouch for stored spans") + require.False(t, idx.IndexedContentMatches("missing.go", plain), "an untracked file has no recorded identity") +} From 1fd426f24275b576084941316e9fcff8ae29b2d0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:22:06 +0100 Subject: [PATCH 004/138] bench/load: report the WAL's p95 over a phase A phase reports only the WAL's peak, and one reclaim-sized burst sets that. The edit-loop budget holds the WAL's p95 in edit loads to the v0.64.10 peak for each scenario, so the monitor needs to say where the WAL spent the phase, not only how high it went once. Keep every WAL size the half-second samples find and report p95_wal_bytes beside peak_wal_bytes, using the report's own quantile rule. Samples that found no WAL file are not counted; a phase that never found one reports the value as unavailable. --- bench/load/monitor.go | 7 ++++ bench/load/monitor_test.go | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 bench/load/monitor_test.go diff --git a/bench/load/monitor.go b/bench/load/monitor.go index 677655a27..d72ee6d35 100644 --- a/bench/load/monitor.go +++ b/bench/load/monitor.go @@ -19,6 +19,7 @@ type phaseMonitor struct { clientBefore map[string]Metric groupBefore map[string]Metric peaks map[string]Metric + walSamples []float64 // every WAL size a sample found, for the p95 cancel context.CancelFunc done sync.WaitGroup } @@ -55,6 +56,9 @@ func (m *phaseMonitor) observe() { values[key] = measured(float64(info.Size()), "bytes") } } + if wal := values["wal_bytes"]; wal.Value != nil { + m.walSamples = append(m.walSamples, *wal.Value) + } for _, key := range []string{"rss_bytes", "pss_bytes", "rss_anon_bytes", "phys_footprint_bytes", "wal_bytes", "store_bytes"} { value := values[key] old, exists := m.peaks[key] @@ -80,6 +84,9 @@ func (m *phaseMonitor) finish(phase *Phase) { for key, value := range m.peaks { phase.Metrics["peak_"+key] = value } + // One reclaim-sized burst sets the peak; the p95 says where the WAL + // spent the phase. + phase.Metrics["p95_wal_bytes"] = distribution(m.walSamples, "bytes").P95 successes := 0 for _, row := range phase.Attempts { if row.Role == "workload" && row.Outcome == "success" { diff --git a/bench/load/monitor_test.go b/bench/load/monitor_test.go new file mode 100644 index 000000000..904042d67 --- /dev/null +++ b/bench/load/monitor_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type absentSampler struct{} + +func (absentSampler) sample(int) map[string]Metric { return absentProcessMetrics("test sampler") } + +// TestMonitorReportsWALP95 samples a store whose WAL sits at 1000 bytes but +// for one burst: the phase reports the burst as the peak and the steady size +// as the p95, counting only samples that found a WAL file. +func TestMonitorReportsWALP95(t *testing.T) { + store := filepath.Join(t.TempDir(), "store.db") + self, err := os.FindProcess(os.Getpid()) + require.NoError(t, err) + m := &phaseMonitor{ + p: &privateRun{Store: store, process: &exec.Cmd{Process: self}}, + sampler: absentSampler{}, + pid: os.Getpid(), + started: time.Now(), + peaks: make(map[string]Metric), + cancel: func() {}, + } + setWAL := func(size int) { + require.NoError(t, os.WriteFile(store+"-wal", make([]byte, size), 0o600)) + } + m.observe() // no WAL yet: not a sample + for i := range 40 { + setWAL(1000) + m.observe() + if i == 20 { + setWAL(1 << 20) + m.observe() + } + } + setWAL(1000) // finish takes one last sample + phase := &Phase{Metrics: make(map[string]Metric)} + m.finish(phase) + + require.NotNil(t, phase.Metrics["peak_wal_bytes"].Value) + require.Equal(t, float64(1<<20), *phase.Metrics["peak_wal_bytes"].Value) + p95 := phase.Metrics["p95_wal_bytes"] + require.NotNil(t, p95.Value, p95.Reason) + require.Equal(t, float64(1000), *p95.Value) + require.Equal(t, "bytes", p95.Unit) + + t.Run("a phase that never found a WAL", func(t *testing.T) { + m := &phaseMonitor{ + p: &privateRun{Store: filepath.Join(t.TempDir(), "store.db"), process: &exec.Cmd{Process: self}}, + sampler: absentSampler{}, + pid: os.Getpid(), + started: time.Now(), + peaks: make(map[string]Metric), + cancel: func() {}, + } + m.observe() + phase := &Phase{Metrics: make(map[string]Metric)} + m.finish(phase) + require.Nil(t, phase.Metrics["p95_wal_bytes"].Value) + require.NotEmpty(t, phase.Metrics["p95_wal_bytes"].Reason) + }) +} From 6b1eed8c516793201c5f516d0dcd3c7cc1d49a7c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:33:21 +0100 Subject: [PATCH 005/138] reach: retry a lookup crossed by a topology change only once lookupContext recomputed for as long as the reach generation kept moving during its traversal, so a lookup on a repository that is being edited continuously spun until its 3 s deadline and only then returned a truncated answer. That spin sat behind every slow change.impact call. Retry once; if the second traversal is crossed as well, return what it read marked truncated, which is what the comment above the loop already promised. A cached record whose generation moved under it now falls through to the traversal instead of looping. An eager build no longer advances the generation it stamps. The records of an unchanged graph are as current as their rebuild, and advancing the generation made every concurrent lazy lookup recompute, which a single retry could not absorb. --- internal/reach/reach.go | 33 ++++++++++--------- internal/reach/reach_retry_test.go | 52 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 internal/reach/reach_retry_test.go diff --git a/internal/reach/reach.go b/internal/reach/reach.go index c3956d1d3..207cc6202 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -314,9 +314,10 @@ func BeginTopologyMutation(g graph.Store) func(changed bool) { // graph-shaping passes settle (resolver, semantic enrichment, cross- // repo edges, gRPC stub resolution). // -// Safe to call repeatedly: existing reach_d* entries are overwritten -// and the build counter advances each time so any consumer that read -// an entry from a prior generation will fall back to a live walk. +// Safe to call repeatedly: existing reach_d* entries are overwritten. +// A build stamps the generation it read the graph at and does not advance +// it: the records of an unchanged graph are as current as their rebuild, +// and advancing it would make every concurrent lookup recompute. func BuildIndex(g graph.Store) *Stats { return BuildIndexCtx(context.Background(), g) } @@ -342,7 +343,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { releaseTopology() return &Stats{} } - build := atomic.AddUint64(&buildCounter, 1) + build := atomic.LoadUint64(&buildCounter) mu.Unlock() releaseTopology() stats := &Stats{Build: build} @@ -803,7 +804,7 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca // LookupContext returns a complete reach record or a bounded lower-bound // record. Expensive BFS work deliberately happens outside topology and // resolver locks. Publication re-enters both locks, verifies the generation, -// and retries if a mutation crossed the optimistic compute window. This keeps +// and retries once if a mutation crossed the optimistic compute window. This keeps // synchronous edits from waiting behind breadth-first graph traversal. type contextNodeGetter interface { GetNodeContext(context.Context, string) (*graph.Node, error) @@ -846,7 +847,7 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed ctx, cancel := context.WithTimeout(parent, lookupTimeout) defer cancel() - for { + for retried := false; ; retried = true { releaseTopology, ok := beginTopologyReadContext(ctx) if !ok { return nil, nil, nil, false, true @@ -871,14 +872,10 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed // A published record describes unfiltered adjacency, so a filtered // lookup always computes. if keep == nil { - if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached { - stable := currentBuild == atomic.LoadUint64(&buildCounter) + if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached && currentBuild == atomic.LoadUint64(&buildCounter) { mu.Unlock() releaseTopology() - if stable { - return d1, d2, d3, true, cachedTruncated - } - continue + return d1, d2, d3, true, cachedTruncated } } mu.Unlock() @@ -892,15 +889,19 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed // also retaining topology/resolve coordination, starving the daemon. // // BuildIndexCtx is the sole persistent reach-cache writer. A lazy miss - // returns its bounded lower-bound evidence directly; a concurrent graph - // generation change marks that evidence truncated rather than retrying - // or publishing a mixed snapshot. + // returns its bounded lower-bound evidence directly. A traversal that a + // topology change crossed is retried once; a second crossing returns + // what it read marked truncated, rather than chasing a repository that + // keeps changing until the deadline. tiers, traversalTruncated := compute(ctx, g, seedID, keep) if ctx.Err() != nil { return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, true } if currentBuild != atomic.LoadUint64(&buildCounter) { - continue + if !retried { + continue + } + traversalTruncated = true } return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, traversalTruncated } diff --git a/internal/reach/reach_retry_test.go b/internal/reach/reach_retry_test.go new file mode 100644 index 000000000..72c8a16e0 --- /dev/null +++ b/internal/reach/reach_retry_test.go @@ -0,0 +1,52 @@ +package reach + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +// churningStore retires every reach generation while a traversal reads the +// seed's callers, so every lazy computation crosses a topology change. +type churningStore struct { + graph.Store + seed string + computes atomic.Int32 +} + +func (s *churningStore) GetInEdgesByNodeIDsContext(_ context.Context, ids []string, _ int) (map[string][]*graph.Edge, bool, error) { + if len(ids) == 1 && ids[0] == s.seed { + s.computes.Add(1) + InvalidateIndex() + } + return s.GetInEdgesByNodeIDs(ids), false, nil +} + +// A lookup crossed by a topology change retries once, then answers with what +// it read as a lower bound instead of spinning until its deadline. +func TestLookupRetriesOnceThenReportsALowerBound(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ID: "seed", Kind: graph.KindFunction, Name: "seed"}) + base.AddNode(&graph.Node{ID: "caller", Kind: graph.KindFunction, Name: "caller"}) + base.AddEdge(&graph.Edge{From: "caller", To: "seed", Kind: graph.EdgeCalls, Confidence: 1}) + store := &churningStore{Store: base, seed: "seed"} + + started := time.Now() + d1, _, _, hit, truncated := LookupContext(t.Context(), store, "seed") + elapsed := time.Since(started) + if got := store.computes.Load(); got != 2 { + t.Fatalf("lookup computed %d times in %s, want one retry", got, elapsed) + } + if !hit || !truncated { + t.Fatalf("hit=%v truncated=%v, want a lower-bound hit", hit, truncated) + } + if got := joinIDs(d1); got != "caller" { + t.Fatalf("d1 = %q, want the callers read as lower-bound evidence", got) + } + if elapsed > lookupTimeout/3 { + t.Fatalf("lookup took %s", elapsed) + } +} From 081912606470659e4f53ff56f2d434fdaf6adf68 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:33:30 +0100 Subject: [PATCH 006/138] reach: read SQLite seeds without the store's ResolveMutex Impact lookups and the eager build polled for the store's ResolveMutex before reading a seed. On SQLite every read runs on its own snapshot and returns decoded copies, so the mutex protected nothing a read could see; it only made impact wait behind any resolver or semantic enrichment pass that held it, for up to the lookup deadline. A store now reports NodeReadsDetached, and reach skips ResolveMutex for reads of such a store. Record writes, the eager build's publication and ClearIndex, still take it on every store: they rewrite whole node rows that a pass holding the mutex may be rewriting in place. --- internal/graph/store_sqlite/reach_coupling.go | 8 +++ internal/reach/reach.go | 46 ++++++++++---- internal/reach/reach_sqlite_test.go | 61 +++++++++++++++++++ 3 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 internal/graph/store_sqlite/reach_coupling.go diff --git a/internal/graph/store_sqlite/reach_coupling.go b/internal/graph/store_sqlite/reach_coupling.go new file mode 100644 index 000000000..edb295960 --- /dev/null +++ b/internal/graph/store_sqlite/reach_coupling.go @@ -0,0 +1,8 @@ +package store_sqlite + +// NodeReadsDetached reports that reads stay consistent without ResolveMutex: +// each one runs on its own SQLite snapshot and returns decoded copies, never +// the rows a resolver pass is rewriting. +func (s *Store) NodeReadsDetached() bool { + return true +} diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 207cc6202..3ef143359 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -233,6 +233,27 @@ func lockContext(ctx context.Context, mu *sync.Mutex) bool { return true } +// detachedNodeReader is a store whose reads stay consistent without its +// ResolveMutex, so a lookup does not wait behind a resolver or enrichment pass +// that holds it. +type detachedNodeReader interface { + NodeReadsDetached() bool +} + +// lockResolve takes g's ResolveMutex for a read, unless g's reads are detached +// from it. A record write always takes the mutex: it rewrites whole node rows +// that a pass holding the mutex may be rewriting too. +func lockResolve(ctx context.Context, g graph.Store) (unlock func(), ok bool) { + if detached, isDetached := g.(detachedNodeReader); isDetached && detached.NodeReadsDetached() { + return func() {}, true + } + mu := g.ResolveMutex() + if !lockContext(ctx, mu) { + return nil, false + } + return mu.Unlock, true +} + // viewGenerationPinned is implemented by a store handle bound to one payload // view generation. A handle that does not report one reads and writes the base // corpus. @@ -338,13 +359,13 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { if !ok { return &Stats{} } - mu := g.ResolveMutex() - if !lockContext(ctx, mu) { + unlock, ok := lockResolve(ctx, g) + if !ok { releaseTopology() return &Stats{} } build := atomic.LoadUint64(&buildCounter) - mu.Unlock() + unlock() releaseTopology() stats := &Stats{Build: build} @@ -385,6 +406,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { } batchCap := min(seedTotal, reachPublishBatchSize) pending := make([]pendingRecord, 0, batchCap) + mu := g.ResolveMutex() publish := func() bool { if len(pending) == 0 { return true @@ -782,8 +804,8 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca if !ok { return nil, nil, nil, false, true } - mu := g.ResolveMutex() - if !lockContext(ctx, mu) { + unlock, ok := lockResolve(ctx, g) + if !ok { releaseTopology() return nil, nil, nil, false, true } @@ -793,7 +815,7 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca d1, d2, d3, truncated, hit = readCached(n, build) } stable := build == atomic.LoadUint64(&buildCounter) - mu.Unlock() + unlock() releaseTopology() if !stable { return nil, nil, nil, false, true @@ -852,20 +874,20 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed if !ok { return nil, nil, nil, false, true } - mu := g.ResolveMutex() - if !lockContext(ctx, mu) { + unlock, ok := lockResolve(ctx, g) + if !ok { releaseTopology() return nil, nil, nil, false, true } currentBuild := atomic.LoadUint64(&buildCounter) n, nodeErr := getNodeContext(ctx, g, seedID) if nodeErr != nil { - mu.Unlock() + unlock() releaseTopology() return nil, nil, nil, true, true } if n == nil || !ImpactSeedKind(n.Kind) { - mu.Unlock() + unlock() releaseTopology() return nil, nil, nil, false, false } @@ -873,12 +895,12 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed // lookup always computes. if keep == nil { if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached && currentBuild == atomic.LoadUint64(&buildCounter) { - mu.Unlock() + unlock() releaseTopology() return d1, d2, d3, true, cachedTruncated } } - mu.Unlock() + unlock() releaseTopology() // The expensive portion is optimistic and lock-free with respect to diff --git a/internal/reach/reach_sqlite_test.go b/internal/reach/reach_sqlite_test.go index a788800e6..2ced82b4c 100644 --- a/internal/reach/reach_sqlite_test.go +++ b/internal/reach/reach_sqlite_test.go @@ -141,3 +141,64 @@ func TestLookupTraversalDoesNotStarveTopologyOrResolverLocks(t *testing.T) { <-done } + +// openSeedAndCaller opens a SQLite store holding one seed and its caller. +func openSeedAndCaller(t *testing.T) *store_sqlite.Store { + t.Helper() + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "reach.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + store.AddBatch([]*graph.Node{ + {ID: "seed", Kind: graph.KindFunction, Name: "seed"}, + {ID: "caller", Kind: graph.KindFunction, Name: "caller"}, + }, []*graph.Edge{{From: "caller", To: "seed", Kind: graph.EdgeCalls, Confidence: 1}}) + return store +} + +// A SQLite read runs on its own snapshot, so a resolver or enrichment pass that +// holds ResolveMutex must not make an impact lookup wait for it. +func TestLookupDoesNotWaitForSQLiteResolveMutex(t *testing.T) { + store := openSeedAndCaller(t) + store.ResolveMutex().Lock() + defer store.ResolveMutex().Unlock() + started := time.Now() + d1, _, _, hit, truncated := LookupContext(t.Context(), store, "seed") + if elapsed := time.Since(started); elapsed > lookupTimeout/3 { + t.Fatalf("lookup waited %s behind ResolveMutex", elapsed) + } + if !hit || truncated || joinIDs(d1) != "caller" { + t.Fatalf("d1=%v hit=%v truncated=%v, want the caller as an exact hit", d1, hit, truncated) + } +} + +// A record write rewrites the seed's whole row, so on SQLite too it waits for a +// pass that holds ResolveMutex while it rewrites rows. +func TestReachRecordWritesWaitForSQLiteResolveMutex(t *testing.T) { + store := openSeedAndCaller(t) + mu := store.ResolveMutex() + + mu.Lock() + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) + BuildIndexCtx(ctx, store) + cancel() + mu.Unlock() + if _, _, _, hit, _ := LookupCached(store, "seed"); hit { + t.Fatal("the eager build published a record while ResolveMutex was held") + } + + mu.Lock() + cleared := make(chan struct{}) + go func() { + ClearIndex(store) + close(cleared) + }() + select { + case <-cleared: + t.Error("ClearIndex rewrote rows while ResolveMutex was held") + case <-time.After(100 * time.Millisecond): + } + mu.Unlock() + <-cleared +} From 99190346d6b3e093b69d5537306409986113d8ee Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 21:08:03 +0100 Subject: [PATCH 007/138] reach: gate and invalidate reach topology per repository Every base-corpus mutation took one process-wide topology writer and bumped one process-wide build counter. With several repositories in a daemon, a patch of one repository waited for a patch of every other, impact lookups on an untouched repository waited behind them, and each patch retired every cached reach record in the store. Keep a whole-graph gate and add per-repository gates, all under one mutex so a mutation takes its gates in one step, with waiters woken by a channel on release instead of a 2 ms poll. BeginRepoTopologyMutation holds the gates of the repositories coupled with its prefix and, when it changed topology, retires only their generation; a record follows its repository's generation, and an unowned seed still follows every change. BeginTopologyMutation keeps the whole-graph behaviour. Repositories are coupled when an edge joins them, directly or through a node no repository owns, because a record of one can then list nodes of the other. The coupling is read from the store in the background, once per epoch and within 2 s. No mutation waits for that read: until it lands a repository mutation holds the whole graph, and a store whose read fails or runs out of budget is not read again. The coupling then grows from what each mutation left behind. The finishing mutation names the files it bound edges at, and one query over the edges sited in those files or incident to their nodes, of every kind, adds the repository pairs they join. Resolver and derived passes bind edges in many places; the edges a mutation leaves at its frontier are the one place that sees all of them. A mutation that cannot name its frontier, and any whole-graph mutation, forgets the coupling. --- internal/graph/store_sqlite/reach_coupling.go | 90 +++ internal/reach/reach.go | 582 ++++++++++++++---- internal/reach/topology_repo_test.go | 287 +++++++++ 3 files changed, 854 insertions(+), 105 deletions(-) create mode 100644 internal/reach/topology_repo_test.go diff --git a/internal/graph/store_sqlite/reach_coupling.go b/internal/graph/store_sqlite/reach_coupling.go index edb295960..1d0311a5f 100644 --- a/internal/graph/store_sqlite/reach_coupling.go +++ b/internal/graph/store_sqlite/reach_coupling.go @@ -1,5 +1,95 @@ package store_sqlite +import ( + "context" +) + +// repoCouplingPairsSQL lists the distinct (source, target) repository pairs an +// edge joins. An empty prefix stands for nodes no repository owns; such a +// target counts only when it has edges of its own, since only then can a path +// run through it from one repository into another. Edges whose endpoints share +// a plain leading "/" segment belong to one repository and are skipped +// before the node joins. That is most of the table, so one sequential scan +// beats walking an index and fetching every row it names. +const repoCouplingPairsSQL = ` +SELECT DISTINCT nf.repo_prefix, nt.repo_prefix +FROM edges e NOT INDEXED +JOIN nodes nf ON nf.id = e.from_id AND nf.view_gen = e.view_gen +JOIN nodes nt ON nt.id = e.to_id AND nt.view_gen = e.view_gen +WHERE e.view_gen = ? + AND NOT (instr(e.from_id, '/') > 1 + AND instr(substr(e.from_id, 1, instr(e.from_id, '/')), ':') = 0 + AND substr(e.to_id, 1, instr(e.from_id, '/')) = substr(e.from_id, 1, instr(e.from_id, '/'))) + AND nf.repo_prefix <> nt.repo_prefix + AND (nt.repo_prefix <> '' OR EXISTS ( + SELECT 1 FROM edges o WHERE o.view_gen = e.view_gen AND o.from_id = e.to_id))` + +// repoCouplingPairsForFilesSQL is repoCouplingPairsSQL over the edges sited in +// a list of files or incident to their nodes. CROSS JOIN keeps that frontier +// the driving loop, as in the cross-repository candidate query. An unowned +// node with an edge into the frontier may have just gained its first one, so +// the repositories with edges into it are listed as coupled with it too. +const repoCouplingPairsForFilesSQL = ` +WITH frontier(id) AS ( + SELECT e.id FROM edges e + WHERE e.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) + UNION + SELECT e.id FROM nodes n JOIN edges e ON e.from_id = n.id AND e.view_gen = n.view_gen + WHERE n.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) + UNION + SELECT e.id FROM nodes n JOIN edges e ON e.to_id = n.id AND e.view_gen = n.view_gen + WHERE n.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) +), +joined(from_id, to_id, from_repo, to_repo) AS ( + SELECT e.from_id, e.to_id, nf.repo_prefix, nt.repo_prefix + FROM frontier f CROSS JOIN edges e ON e.id = f.id + JOIN nodes nf ON nf.id = e.from_id AND nf.view_gen = e.view_gen + JOIN nodes nt ON nt.id = e.to_id AND nt.view_gen = e.view_gen + WHERE e.view_gen = ? AND nf.repo_prefix <> nt.repo_prefix +) +SELECT from_repo, to_repo FROM joined j +WHERE j.to_repo <> '' OR EXISTS (SELECT 1 FROM edges o WHERE o.view_gen = ? AND o.from_id = j.to_id) +UNION +SELECT ni.repo_prefix, '' +FROM joined j +JOIN edges i ON i.to_id = j.from_id AND i.view_gen = ? +JOIN nodes ni ON ni.id = i.from_id AND ni.view_gen = i.view_gen +WHERE j.from_repo = '' AND ni.repo_prefix <> ''` + +// RepoCouplingPairs reports which repositories the graph's edges join, so the +// reach index can tell which repositories a mutation of one may affect. It +// reads every edge of the handle's generation. +func (s *Store) RepoCouplingPairs(ctx context.Context) ([][2]string, error) { + return s.repoCouplingPairs(ctx, repoCouplingPairsSQL, s.viewGen) +} + +// RepoCouplingPairsForFiles is RepoCouplingPairs for the edges a mutation of +// the given files can have added or rebound. +func (s *Store) RepoCouplingPairsForFiles(ctx context.Context, files []string) ([][2]string, error) { + scope, ok := projectionJSON(files) + if !ok { + return nil, nil + } + return s.repoCouplingPairs(ctx, repoCouplingPairsForFilesSQL, scope, scope, scope, s.viewGen, s.viewGen, s.viewGen) +} + +func (s *Store) repoCouplingPairs(ctx context.Context, query string, args ...any) ([][2]string, error) { + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var pairs [][2]string + for rows.Next() { + var pair [2]string + if err := rows.Scan(&pair[0], &pair[1]); err != nil { + return nil, err + } + pairs = append(pairs, pair) + } + return pairs, rows.Err() +} + // NodeReadsDetached reports that reads stay consistent without ResolveMutex: // each one runs on its own SQLite snapshot and returns decoded copies, never // the rows a resolver pass is rewriting. diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 3ef143359..f9faec179 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -123,13 +123,14 @@ type Stats struct { EntriesD1 int // total reach_d1 IDs across all indexed nodes EntriesD2 int // total reach_d2 IDs EntriesD3 int // total reach_d3 IDs - Build uint64 // generation tag stamped on every indexed node + Build uint64 // generation the build read the graph at } -// buildCounter is a process-wide monotonic generation counter used to -// invalidate cached reach sets after graph mutations and incremental rebuilds. -// reachProcessEpoch prevents a counter value reused by a restarted process from -// matching a record persisted by the previous process. +// buildCounter is the process-wide source of reach generations: every +// invalidation, of the whole graph or of some repositories, takes its next +// value, so a generation never repeats within a process. reachProcessEpoch +// prevents a counter value reused by a restarted process from matching a +// record persisted by the previous process. var ( buildCounter uint64 reachProcessEpoch = newReachProcessEpoch() @@ -145,81 +146,196 @@ func newReachProcessEpoch() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } -// topologyGate serialises impact readers/eager reach maintenance with a -// watcher topology transaction. It is separate from Store.ResolveMutex: -// watcher indexing calls resolver methods that acquire ResolveMutex -// internally, so holding that non-reentrant mutex around the whole patch would -// deadlock. Readers always acquire this gate before ResolveMutex; a watcher -// holds the writer side while its nested resolver calls take ResolveMutex. +// topologyGate is the writer-preferring reader/writer state of one scope: the +// whole graph, or one repository prefix. type topologyGate struct { - mu sync.Mutex - cond *sync.Cond readers int - writer bool + writers int writersWaiting int } -// buildCounter is already process-global, so topology publication uses one -// process-global gate as well. This avoids retaining one registry entry per -// short-lived Store forever (notably across tests and workspace reloads). -// Production multi-repo indexers share one Store, and the conservative -// cross-store serialization is preferable to an unbounded coordination -// registry — but only while what passes through it stays scarce. Every -// checkout coordinator, ref view and warmup tail in the daemon queues on this -// one gate, so a pass that cannot change what the records describe must not -// enter it at all: see WritesBaseTopology. -var globalTopologyGate = func() *topologyGate { - gate := &topologyGate{} - gate.cond = sync.NewCond(&gate.mu) - return gate -}() - -func beginTopologyRead(_ graph.Store) func() { - gate := globalTopologyGate - gate.mu.Lock() - for gate.writer || gate.writersWaiting > 0 { - gate.cond.Wait() - } - gate.readers++ - gate.mu.Unlock() +// topologyState serialises impact readers and eager reach maintenance with +// watcher topology transactions, per repository. It is separate from +// Store.ResolveMutex: watcher indexing calls resolver methods that acquire +// ResolveMutex internally, so holding that non-reentrant mutex around the +// whole patch would deadlock. Readers always acquire a gate before +// ResolveMutex; a watcher holds the writer side while its nested resolver +// calls take ResolveMutex. +// +// A scope is nil for the whole graph, or a sorted list of repository +// prefixes. One mutex guards every gate, so a mutation takes all of its gates +// in one step and gates need no lock order among themselves. Waiters sleep on +// wake, which is closed and replaced whenever a holder leaves. +// +// The state is process-global, like buildCounter. Repository gates are created +// on first use and dropped once idle, so the map holds only work in flight. +// Production multi-repo indexers share one Store; tests that reuse a prefix +// across stores only over-serialise. +type topologyState struct { + mu sync.Mutex + wake chan struct{} + all topologyGate + repos map[string]*topologyGate + // Readers, writers and waiting writers of repository scopes, so that + // whole-graph admission need not scan repos. + repoReaders, repoWriters, repoWritersWaiting int + + // allBuild is the generation of the last whole-graph invalidation, and + // repoBuild the generation of each repository invalidated since. + allBuild uint64 + repoBuild map[string]uint64 + + coupling repoCoupling +} - return func() { - gate.mu.Lock() - gate.readers-- - if gate.readers == 0 { - gate.cond.Broadcast() +var topology = &topologyState{ + wake: make(chan struct{}), + repos: make(map[string]*topologyGate), + repoBuild: make(map[string]uint64), +} + +// readable reports whether a reader of scope may enter: no writer holds or +// awaits any gate the reader needs. t.mu is held. +func (t *topologyState) readable(scope []string) bool { + if t.all.writers > 0 || t.all.writersWaiting > 0 { + return false + } + if scope == nil { + return t.repoWriters == 0 && t.repoWritersWaiting == 0 + } + for _, prefix := range scope { + if gate := t.repos[prefix]; gate != nil && (gate.writers > 0 || gate.writersWaiting > 0) { + return false } - gate.mu.Unlock() } + return true } -// beginTopologyReadContext is the cancellable counterpart used by lazy -// impact lookups. It never waits on sync.Cond (which has no cancellation -// channel); a short poll keeps the writer-preference semantics while allowing -// an expired tool request to stop waiting. -func beginTopologyReadContext(ctx context.Context) (func(), bool) { - gate := globalTopologyGate - for { - gate.mu.Lock() - if !gate.writer && gate.writersWaiting == 0 { - gate.readers++ - gate.mu.Unlock() - return func() { - gate.mu.Lock() - gate.readers-- - if gate.readers == 0 { - gate.cond.Broadcast() - } - gate.mu.Unlock() - }, true +// writable reports whether a writer of scope may enter. A repository writer +// also yields to a waiting whole-graph writer so the latter cannot starve. +// t.mu is held. +func (t *topologyState) writable(scope []string) bool { + if t.all.writers > 0 || t.all.readers > 0 { + return false + } + if scope == nil { + return t.repoReaders == 0 && t.repoWriters == 0 + } + if t.all.writersWaiting > 0 { + return false + } + for _, prefix := range scope { + if gate := t.repos[prefix]; gate != nil && (gate.writers > 0 || gate.readers > 0) { + return false } - gate.mu.Unlock() - select { - case <-ctx.Done(): + } + return true +} + +// add moves every gate of scope by the given deltas. t.mu is held. +func (t *topologyState) add(scope []string, readers, writers, waiting int) { + if scope == nil { + t.all.readers += readers + t.all.writers += writers + t.all.writersWaiting += waiting + return + } + t.repoReaders += readers + t.repoWriters += writers + t.repoWritersWaiting += waiting + for _, prefix := range scope { + gate := t.repos[prefix] + if gate == nil { + gate = &topologyGate{} + t.repos[prefix] = gate + } + gate.readers += readers + gate.writers += writers + gate.writersWaiting += waiting + if *gate == (topologyGate{}) { + delete(t.repos, prefix) + } + } +} + +// wait sleeps until a holder leaves and reports false when ctx ends first. +// t.mu is held on entry and on return. +func (t *topologyState) wait(ctx context.Context) bool { + wake := t.wake + t.mu.Unlock() + defer t.mu.Lock() + select { + case <-wake: + return true + case <-ctx.Done(): + return false + } +} + +// wakeWaiters lets every waiter re-check its admission. t.mu is held. +func (t *topologyState) wakeWaiters() { + close(t.wake) + t.wake = make(chan struct{}) +} + +// retire moves the reach generation of scope past every value issued so far. +// t.mu is held. +func (t *topologyState) retire(scope []string) { + build := atomic.AddUint64(&buildCounter, 1) + if scope == nil { + t.allBuild = build + clear(t.repoBuild) + return + } + for _, prefix := range scope { + t.repoBuild[prefix] = build + } +} + +// retireWhole is retire for the whole graph, taking t.mu. +func retireWhole() { + topology.mu.Lock() + defer topology.mu.Unlock() + topology.retire(nil) +} + +// generation is the build a current record of a seed in scope carries. A seed +// no repository owns follows every invalidation. +func generation(scope []string) uint64 { + if scope == nil { + return atomic.LoadUint64(&buildCounter) + } + t := topology + t.mu.Lock() + defer t.mu.Unlock() + build := t.allBuild + for _, prefix := range scope { + build = max(build, t.repoBuild[prefix]) + } + return build +} + +// beginTopologyRead waits until no writer holds or awaits a gate of scope, or +// until ctx ends. +func beginTopologyRead(ctx context.Context, scope []string) (func(), bool) { + t := topology + t.mu.Lock() + for !t.readable(scope) { + if !t.wait(ctx) { + t.mu.Unlock() return nil, false - case <-time.After(lockPoll): } } + t.add(scope, 1, 0, 0) + t.mu.Unlock() + return func() { + t.mu.Lock() + t.add(scope, -1, 0, 0) + if t.all.writersWaiting > 0 || t.repoWritersWaiting > 0 { + t.wakeWaiters() + } + t.mu.Unlock() + }, true } func lockContext(ctx context.Context, mu *sync.Mutex) bool { @@ -284,44 +400,277 @@ func WritesBaseTopology(g graph.Store) bool { } // BeginTopologyMutation prevents reach lookups/builds from observing a -// watcher's parse-then-swap and resolver work half-applied. The returned -// function must be called exactly once; changed=true invalidates every cached -// generation before waiting readers are released. A separate gate is required -// because the mutation body itself invokes resolver methods that acquire the -// store's non-reentrant ResolveMutex. +// watcher's parse-then-swap and resolver work half-applied. It holds the whole +// graph: use BeginRepoTopologyMutation when the mutation belongs to one +// repository. The returned function must be called exactly once; changed=true +// invalidates every cached generation, and the repository coupling, before +// waiting readers are released. A separate gate is required because the +// mutation body itself invokes resolver methods that acquire the store's +// non-reentrant ResolveMutex. // // A mutation that cannot reach the base corpus takes nothing and invalidates -// nothing. The gate is process-global, so a generation-pinned build that held -// it would serialise every unrelated store and pass in the daemon behind a +// nothing. The gates are process-global, so a generation-pinned build that held +// them would serialise every unrelated store and pass in the daemon behind a // pass whose duration is its payload's, not the corpus's. func BeginTopologyMutation(g graph.Store) func(changed bool) { if !WritesBaseTopology(g) { return func(bool) {} } - gate := globalTopologyGate - gate.mu.Lock() - gate.writersWaiting++ - for gate.writer || gate.readers > 0 { - gate.cond.Wait() + finish := beginTopologyWrite(g, "") + return func(changed bool) { finish(changed, nil) } +} + +// BeginRepoTopologyMutation is BeginTopologyMutation for a mutation of the +// rows of repository prefix. It waits only for the readers and writers of the +// repositories coupled with prefix. The whole graph is held instead for an +// empty prefix, and while the store's coupling is unknown; the first mutation +// that needs it starts reading it in the background. +// +// The returned function must be called exactly once. frontier lists the files +// the mutation may have added or rebound edges in or at the nodes of. With +// changed=true, the couplings those edges make are learned first, and then the +// records of every repository coupled with prefix are invalidated. A nil +// frontier says edges anywhere may have moved: every record is invalidated and +// the coupling forgotten. +func BeginRepoTopologyMutation(g graph.Store, prefix string) func(changed bool, frontier []string) { + if !WritesBaseTopology(g) { + return func(bool, []string) {} + } + if prefix != "" { + ensureRepoCoupling(g) + } + return beginTopologyWrite(g, prefix) +} + +func beginTopologyWrite(g graph.Store, prefix string) func(changed bool, frontier []string) { + t := topology + t.mu.Lock() + scope, epoch := t.writeScope(g, prefix) + t.add(scope, 0, 0, 1) + for !t.writable(scope) { + t.wait(context.Background()) + if epoch != t.coupling.epoch { + // The coupling the scope came from was retired while this writer + // waited. + t.add(scope, 0, 0, -1) + scope, epoch = t.writeScope(g, prefix) + t.add(scope, 0, 0, 1) + } } - gate.writersWaiting-- - gate.writer = true - gate.mu.Unlock() + t.add(scope, 0, 1, -1) + t.mu.Unlock() var once sync.Once - return func(changed bool) { + return func(changed bool, frontier []string) { once.Do(func() { + if changed && prefix != "" && frontier != nil { + learnRepoCoupling(g, frontier) + } + t.mu.Lock() if changed { - InvalidateIndex() + if prefix == "" || frontier == nil { + // Edges anywhere may have moved. + t.coupling.forget(g) + } + // What the mutation held, widened by the couplings it bound; the + // whole graph when either is unknown. + retired, _ := t.writeScope(g, prefix) + if scope == nil || retired == nil { + retired = nil + } else { + retired = append(retired, scope...) + } + t.retire(retired) } - gate.mu.Lock() - gate.writer = false - gate.cond.Broadcast() - gate.mu.Unlock() + t.add(scope, 0, -1, 0) + t.wakeWaiters() + t.mu.Unlock() }) } } +// writeScope is the gate set a mutation of prefix holds: the repositories +// coupled with it, or the whole graph (nil) for an empty prefix or while the +// coupling of g is unknown. The epoch tells a waiting writer when to ask +// again. t.mu is held. +func (t *topologyState) writeScope(g graph.Store, prefix string) ([]string, uint64) { + c := &t.coupling + if prefix == "" || !c.ready || c.store != g { + return nil, c.epoch + } + return c.component(prefix), c.epoch +} + +// repoCoupling groups the repositories of one store whose reach records can +// cross: an edge joins them, directly or through a node no repository owns. A +// mutation of one member can change the records of every member of its group. +// +// It is read from the store in the background once per epoch, grows with what +// each repository mutation learns from its frontier, and is forgotten by any +// mutation that may have moved edges anywhere. A store whose read fails is not +// read again. Only one store is tracked; a mutation of another store holds the +// whole graph until it is read again. +type repoCoupling struct { + store graph.Store + epoch uint64 + ready bool + seeding bool + missed bool + // pending holds what mutations learned while the read was in flight. + pending [][2]string + parent map[string]string +} + +// couplingReadBudget bounds each coupling read. A store whose read misses it +// keeps whole-graph mutations until the process restarts. +const couplingReadBudget = 2 * time.Second + +// repoCouplingSource reports the distinct repository pairs the store's edges +// join: all of them, or those at the nodes of some files. +type repoCouplingSource interface { + RepoCouplingPairs(ctx context.Context) ([][2]string, error) + RepoCouplingPairsForFiles(ctx context.Context, files []string) ([][2]string, error) +} + +func (c *repoCoupling) find(prefix string) string { + for { + parent, ok := c.parent[prefix] + if !ok || parent == prefix { + return prefix + } + prefix = parent + } +} + +func (c *repoCoupling) union(a, b string) { + ra, rb := c.find(a), c.find(b) + if ra == rb { + return + } + if c.parent == nil { + c.parent = make(map[string]string) + } + c.parent[ra] = rb + c.parent[rb] = rb +} + +// component lists prefix and every repository coupled with it, sorted. +func (c *repoCoupling) component(prefix string) []string { + root := c.find(prefix) + scope := []string{prefix} + for member := range c.parent { + if member != prefix && c.find(member) == root { + scope = append(scope, member) + } + } + sort.Strings(scope) + return scope +} + +// reset starts a new epoch for g with nothing known. A store whose read failed +// stays missed. +func (c *repoCoupling) reset(g graph.Store) { + if c.store != g { + c.missed = false + } + c.store, c.epoch = g, c.epoch+1 + c.ready, c.seeding, c.pending, c.parent = false, false, nil, nil +} + +// forget drops what is known about g's coupling. +func (c *repoCoupling) forget(g graph.Store) { + if c.store == g { + c.reset(g) + } +} + +// miss gives up on the current store's coupling for the life of the process. +func (c *repoCoupling) miss() { + c.reset(c.store) + c.missed = true +} + +// ensureRepoCoupling starts reading g's coupling in the background, once per +// epoch. Nothing waits for the read. +func ensureRepoCoupling(g graph.Store) { + source, ok := g.(repoCouplingSource) + if !ok { + return + } + t := topology + t.mu.Lock() + defer t.mu.Unlock() + c := &t.coupling + if c.store != g { + c.reset(g) + } + if c.ready || c.seeding || c.missed { + return + } + c.seeding = true + go readRepoCoupling(g, source, c.epoch) +} + +// readRepoCoupling reads g's coupling for epoch. It takes no gate, so its +// snapshot can miss edges of a mutation that runs beside it; such a mutation +// learns its own couplings into pending, which the read adds on landing. +func readRepoCoupling(g graph.Store, source repoCouplingSource, epoch uint64) { + ctx, cancel := context.WithTimeout(context.Background(), couplingReadBudget) + pairs, err := source.RepoCouplingPairs(ctx) + cancel() + t := topology + t.mu.Lock() + defer t.mu.Unlock() + c := &t.coupling + if c.store != g || c.epoch != epoch { + return + } + if err != nil { + c.miss() + return + } + for _, pair := range append(pairs, c.pending...) { + c.union(pair[0], pair[1]) + } + c.ready, c.seeding, c.pending = true, false, nil +} + +// learnRepoCoupling adds the couplings that the edges at frontier make to g's +// coupling, while it is known or being read. A failed read gives the coupling +// up, since what the mutation bound is then unknown. +func learnRepoCoupling(g graph.Store, frontier []string) { + source, ok := g.(repoCouplingSource) + if !ok || len(frontier) == 0 { + return + } + t := topology + t.mu.Lock() + c := &t.coupling + epoch, wanted := c.epoch, c.store == g && (c.ready || c.seeding) + t.mu.Unlock() + if !wanted { + return + } + ctx, cancel := context.WithTimeout(context.Background(), couplingReadBudget) + pairs, err := source.RepoCouplingPairsForFiles(ctx, frontier) + cancel() + + t.mu.Lock() + defer t.mu.Unlock() + switch { + case c.store != g || c.epoch != epoch: + // Forgotten meanwhile: the next read sees these edges. + case err != nil: + c.miss() + case c.seeding: + c.pending = append(c.pending, pairs...) + default: + for _, pair := range pairs { + c.union(pair[0], pair[1]) + } + } +} + // BuildIndex precomputes per-depth incoming reachability sets for // every impact-seed node in g and stores them under Node.Meta as // []string slices keyed reach_d1 / reach_d2 / reach_d3. Tiers are @@ -336,9 +685,10 @@ func BeginTopologyMutation(g graph.Store) func(changed bool) { // repo edges, gRPC stub resolution). // // Safe to call repeatedly: existing reach_d* entries are overwritten. -// A build stamps the generation it read the graph at and does not advance -// it: the records of an unchanged graph are as current as their rebuild, -// and advancing it would make every concurrent lookup recompute. +// Each record carries its repository's generation as of the build, which +// the build does not advance: the records of an unchanged graph are as +// current as their rebuild, and advancing it would make every concurrent +// lookup recompute. func BuildIndex(g graph.Store) *Stats { return BuildIndexCtx(context.Background(), g) } @@ -355,7 +705,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { return &Stats{} } reporter := progress.FromContext(ctx) - releaseTopology, ok := beginTopologyReadContext(ctx) + releaseTopology, ok := beginTopologyRead(ctx, nil) if !ok { return &Stats{} } @@ -411,7 +761,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { if len(pending) == 0 { return true } - release, acquired := beginTopologyReadContext(ctx) + release, acquired := beginTopologyRead(ctx, nil) if !acquired { return false } @@ -431,7 +781,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { continue } published := cloneNodeWithMeta(n) - writeRecord(published.Meta, build, record.tiers, record.truncated) + writeRecord(published.Meta, generation(nodeScope(n)), record.tiers, record.truncated) stamped = append(stamped, published) } if len(stamped) > 0 { @@ -724,12 +1074,12 @@ func ClearIndex(g graph.Store) { if g == nil { return } - releaseTopology := beginTopologyRead(g) + releaseTopology, _ := beginTopologyRead(context.Background(), nil) defer releaseTopology() mu := g.ResolveMutex() mu.Lock() defer mu.Unlock() - atomic.AddUint64(&buildCounter, 1) + retireWhole() cleared := make([]*graph.Node, 0, reachPublishBatchSize) for _, n := range g.AllNodes() { if n == nil || !hasReachMeta(n.Meta) { @@ -800,7 +1150,8 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca } ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) defer cancel() - releaseTopology, ok := beginTopologyReadContext(ctx) + scope := seedScope(ctx, g, seedID) + releaseTopology, ok := beginTopologyRead(ctx, scope) if !ok { return nil, nil, nil, false, true } @@ -809,12 +1160,12 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca releaseTopology() return nil, nil, nil, false, true } - build := atomic.LoadUint64(&buildCounter) + build := generation(scope) n := g.GetNode(seedID) if n != nil && ImpactSeedKind(n.Kind) { d1, d2, d3, truncated, hit = readCached(n, build) } - stable := build == atomic.LoadUint64(&buildCounter) + stable := build == generation(scope) unlock() releaseTopology() if !stable { @@ -869,8 +1220,9 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed ctx, cancel := context.WithTimeout(parent, lookupTimeout) defer cancel() + scope := seedScope(ctx, g, seedID) for retried := false; ; retried = true { - releaseTopology, ok := beginTopologyReadContext(ctx) + releaseTopology, ok := beginTopologyRead(ctx, scope) if !ok { return nil, nil, nil, false, true } @@ -879,7 +1231,7 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed releaseTopology() return nil, nil, nil, false, true } - currentBuild := atomic.LoadUint64(&buildCounter) + currentBuild := generation(scope) n, nodeErr := getNodeContext(ctx, g, seedID) if nodeErr != nil { unlock() @@ -894,7 +1246,7 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed // A published record describes unfiltered adjacency, so a filtered // lookup always computes. if keep == nil { - if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached && currentBuild == atomic.LoadUint64(&buildCounter) { + if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached && currentBuild == generation(scope) { unlock() releaseTopology() return d1, d2, d3, true, cachedTruncated @@ -919,7 +1271,7 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed if ctx.Err() != nil { return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, true } - if currentBuild != atomic.LoadUint64(&buildCounter) { + if currentBuild != generation(scope) { if !retried { continue } @@ -929,6 +1281,26 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed } } +// seedScope is the gate scope a lookup of seedID waits on: the seed's +// repository, or the whole graph for a seed no repository owns or that is +// missing. It only routes the lookup, so it reads the metadata-free projection +// outside any gate; the lookup reads the seed itself under the gate. +func seedScope(ctx context.Context, g graph.Store, seedID string) []string { + rows, err := graph.GetNodeKindsByIDsContext(ctx, g, []string{seedID}) + if err != nil || rows[seedID].RepoPrefix == "" { + return nil + } + return []string{rows[seedID].RepoPrefix} +} + +// nodeScope is the scope whose generation a record on n follows. +func nodeScope(n *graph.Node) []string { + if n == nil || n.RepoPrefix == "" { + return nil + } + return []string{n.RepoPrefix} +} + func entriesForTier(t tier) []Entry { if len(t.IDs) == 0 { return nil @@ -1038,8 +1410,8 @@ func safeUint64(v any) (uint64, bool) { return 0, false } -// InvalidateIndex advances the global build counter so every future -// Lookup recomputes against the new graph state. Call this whenever +// InvalidateIndex advances the generation of every repository so every +// future Lookup recomputes against the new graph state. Call this whenever // the graph mutates in a way that could change reach sets — at the // end of every IndexCtx / IncrementalReindexPaths / global-pass run. // @@ -1050,7 +1422,7 @@ func safeUint64(v any) (uint64, bool) { // invalidation is O(1) and only the seeds actually queried pay the // recompute cost. func InvalidateIndex() { - atomic.AddUint64(&buildCounter, 1) + retireWhole() } // readTier reconstructs an []Entry from the parallel arrays. Missing diff --git a/internal/reach/topology_repo_test.go b/internal/reach/topology_repo_test.go new file mode 100644 index 000000000..36c6b56ce --- /dev/null +++ b/internal/reach/topology_repo_test.go @@ -0,0 +1,287 @@ +package reach + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +const ( + seedA = "a/a.go::A" + seedB = "b/b.go::B" +) + +// repoTopologyStore holds repositories a and b, one caller-callee pair in a and +// one function in b, plus the extra edges given. +func repoTopologyStore(t *testing.T, extra ...*graph.Edge) *store_sqlite.Store { + t.Helper() + store := openTopologyScopeStore(t) + store.AddBatch([]*graph.Node{ + {ID: seedA, Kind: graph.KindFunction, Name: "A", FilePath: "a/a.go", RepoPrefix: "a"}, + {ID: "a/a.go::CallsA", Kind: graph.KindFunction, Name: "CallsA", FilePath: "a/a.go", RepoPrefix: "a"}, + {ID: seedB, Kind: graph.KindFunction, Name: "B", FilePath: "b/b.go", RepoPrefix: "b"}, + {ID: "dep::x::X", Kind: graph.KindFunction, Name: "X"}, + }, append([]*graph.Edge{ + {From: "a/a.go::CallsA", To: seedA, Kind: graph.EdgeCalls, Confidence: 1}, + }, extra...)) + return store +} + +// awaitCoupling waits until done holds for the coupling of g. +func awaitCoupling(t *testing.T, g graph.Store, done func(*repoCoupling) bool) { + t.Helper() + deadline := time.Now().Add(topologyScopeWait) + for { + topology.mu.Lock() + ok := topology.coupling.store == g && done(&topology.coupling) + topology.mu.Unlock() + if ok { + return + } + if time.Now().After(deadline) { + t.Fatal("the repository coupling read never landed") + } + time.Sleep(time.Millisecond) + } +} + +// awaitRepoCoupling reads the coupling of g and waits until it is known. +func awaitRepoCoupling(t *testing.T, g graph.Store) { + t.Helper() + ensureRepoCoupling(g) + awaitCoupling(t, g, func(c *repoCoupling) bool { return c.ready }) +} + +// admitted reports whether begin returns within topologyScopeWait. +func admitted[F any](begin func() F) (F, bool) { + done := make(chan F, 1) + go func() { done <- begin() }() + select { + case finish := <-done: + return finish, true + case <-time.After(topologyScopeWait): + var none F + return none, false + } +} + +func lookupWithin(t *testing.T, g graph.Store, seed string, within time.Duration) bool { + t.Helper() + done := make(chan bool, 1) + go func() { + _, _, _, hit, truncated := LookupContext(context.Background(), g, seed) + done <- hit && !truncated + }() + select { + case exact := <-done: + if !exact { + t.Fatalf("lookup of %s was not an exact hit", seed) + } + return true + case <-time.After(within): + return false + } +} + +func TestRepoTopologyMutationsHoldOnlyTheirRepository(t *testing.T) { + store := repoTopologyStore(t) + awaitRepoCoupling(t, store) + finishA := BeginRepoTopologyMutation(store, "a") + + finishB, ok := admitted(func() func(bool, []string) { return BeginRepoTopologyMutation(store, "b") }) + if !ok { + finishA(false, nil) + t.Fatal("a mutation of b queued behind a mutation of a") + } + finishB(false, nil) + if !lookupWithin(t, store, seedB, topologyScopeWait) { + finishA(false, nil) + t.Fatal("a lookup in b waited for a mutation of a") + } + + blocked := make(chan bool, 1) + go func() { + _, _, _, hit, truncated := LookupContext(context.Background(), store, seedA) + blocked <- hit && !truncated + }() + whole := make(chan func(bool), 1) + go func() { whole <- BeginTopologyMutation(store) }() + select { + case <-blocked: + t.Fatal("a lookup in a escaped a mutation of a") + case <-whole: + t.Fatal("a whole-graph mutation entered beside a mutation of a") + case <-time.After(20 * time.Millisecond): + } + finishA(true, []string{"a/a.go"}) + finishWhole := <-whole + finishWhole(false) + select { + case exact := <-blocked: + if !exact { + t.Fatal("the lookup released by the mutation was not an exact hit") + } + case <-time.After(topologyScopeWait): + t.Fatal("a lookup in a stayed blocked after the mutation finished") + } +} + +func TestRepoTopologyMutationRetiresCoupledRecords(t *testing.T) { + bFiles := []string{"b/b.go"} + cases := []struct { + name string + extra []*graph.Edge + bind []*graph.Edge // bound inside the mutation of b + prefix string + frontier []string + retireA bool + }{ + {name: "independent", prefix: "b", frontier: bFiles}, + {name: "joined by an edge", prefix: "b", frontier: bFiles, retireA: true, extra: []*graph.Edge{ + {From: seedB, To: seedA, Kind: graph.EdgeCalls, Confidence: 1}, + }}, + {name: "joined through an unowned node", prefix: "b", frontier: bFiles, retireA: true, extra: []*graph.Edge{ + {From: seedB, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}, + {From: "dep::x::X", To: seedA, Kind: graph.EdgeValueFlow, Confidence: 1}, + }}, + {name: "joined by a reference the mutation bound", prefix: "b", frontier: bFiles, retireA: true, bind: []*graph.Edge{ + {From: seedB, To: seedA, Kind: graph.EdgeReferences, Confidence: 1}, + }}, + {name: "joined through an unowned node the mutation reached", prefix: "b", frontier: bFiles, retireA: true, + extra: []*graph.Edge{{From: "dep::x::X", To: seedA, Kind: graph.EdgeValueFlow, Confidence: 1}}, + bind: []*graph.Edge{{From: seedB, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}}, + }, + {name: "edges anywhere", prefix: "b", retireA: true}, + {name: "unknown repository", prefix: "", frontier: bFiles, retireA: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := repoTopologyStore(t, tc.extra...) + awaitRepoCoupling(t, store) + BuildIndex(store) + finish := BeginRepoTopologyMutation(store, tc.prefix) + if tc.bind != nil { + store.AddBatch(nil, tc.bind) + } + finish(true, tc.frontier) + + if _, _, _, hit, _ := LookupCached(store, seedB); hit { + t.Fatal("the mutated repository kept its records") + } + if _, _, _, hit, _ := LookupCached(store, seedA); hit == tc.retireA { + t.Fatalf("record of a hit=%v after a mutation of %q, want %v", hit, tc.prefix, !tc.retireA) + } + }) + } +} + +// An unowned node that gains its first edge into a repository joins it with +// every repository that has an edge into the node. +func TestRepoCouplingLearnsAnUnownedNodeTurnedHub(t *testing.T) { + store := repoTopologyStore(t, &graph.Edge{From: seedA, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}) + awaitRepoCoupling(t, store) + finish := BeginRepoTopologyMutation(store, "b") + store.AddBatch(nil, []*graph.Edge{{From: "dep::x::X", To: seedB, Kind: graph.EdgeValueFlow, Confidence: 1}}) + finish(true, []string{"b/b.go"}) + + BuildIndex(store) + finish = BeginRepoTopologyMutation(store, "a") + store.AddBatch( + []*graph.Node{{ID: "a/c.go::C", Kind: graph.KindFunction, Name: "C", FilePath: "a/c.go", RepoPrefix: "a"}}, + []*graph.Edge{{From: "a/c.go::C", To: seedA, Kind: graph.EdgeCalls, Confidence: 1}}, + ) + finish(true, []string{"a/c.go"}) + if _, _, _, hit, _ := LookupCached(store, seedB); hit { + t.Fatal("a new caller in a reaches b through an unowned node, but the records of b were kept") + } +} + +// A whole-graph mutation can join repositories anywhere, so the coupling is +// read again before a mutation of one repository narrows. +func TestWholeGraphMutationForgetsRepoCoupling(t *testing.T) { + store := repoTopologyStore(t) + awaitRepoCoupling(t, store) + + finish := BeginTopologyMutation(store) + store.AddBatch(nil, []*graph.Edge{{From: seedB, To: seedA, Kind: graph.EdgeCalls, Confidence: 1}}) + finish(true) + + awaitRepoCoupling(t, store) + BuildIndex(store) + BeginRepoTopologyMutation(store, "b")(true, []string{}) + if _, _, _, hit, _ := LookupCached(store, seedA); hit { + t.Fatal("a mutation of b kept the records of a after an edge joined them") + } +} + +// Without a coupling source every repository mutation holds the whole graph. +func TestRepoTopologyMutationWithoutCouplingHoldsTheWholeGraph(t *testing.T) { + g, ids := newCallChain(t, 3) + BuildIndex(g) + BeginRepoTopologyMutation(g, "b")(true, []string{}) + if _, _, _, hit, _ := LookupCached(g, ids[2]); hit { + t.Fatal("a repository mutation of a store without coupling kept unrelated records") + } +} + +// slowCouplingStore holds its coupling read until release and then reports no +// pairs, or err. +type slowCouplingStore struct { + *store_sqlite.Store + release chan struct{} + err error + reads atomic.Int32 +} + +func (s *slowCouplingStore) RepoCouplingPairs(ctx context.Context) ([][2]string, error) { + s.reads.Add(1) + select { + case <-s.release: + case <-ctx.Done(): + } + return nil, s.err +} + +// A mutation never waits for the coupling read; it holds the whole graph +// until the read lands, and what it bound meanwhile is added to the read's +// result, whose snapshot may predate it. +func TestRepoTopologyMutationDoesNotWaitForTheCouplingRead(t *testing.T) { + store := &slowCouplingStore{Store: repoTopologyStore(t), release: make(chan struct{})} + started := time.Now() + finish := BeginRepoTopologyMutation(store, "b") + store.AddBatch(nil, []*graph.Edge{{From: seedB, To: seedA, Kind: graph.EdgeReferences, Confidence: 1}}) + finish(true, []string{"b/b.go"}) + if waited := time.Since(started); waited > time.Second { + t.Fatalf("a mutation waited %s for the coupling read", waited) + } + + close(store.release) + awaitCoupling(t, store, func(c *repoCoupling) bool { return c.ready }) + BuildIndex(store) + BeginRepoTopologyMutation(store, "b")(true, []string{}) + if _, _, _, hit, _ := LookupCached(store, seedA); hit { + t.Fatal("the coupling lost an edge bound while it was being read") + } +} + +// A store whose coupling read fails, as one that runs out of budget does, +// keeps whole-graph mutations and is not read again. +func TestFailedCouplingReadIsNotRetried(t *testing.T) { + store := &slowCouplingStore{Store: repoTopologyStore(t), release: make(chan struct{}), err: context.DeadlineExceeded} + BeginRepoTopologyMutation(store, "b")(false, nil) + close(store.release) + awaitCoupling(t, store, func(c *repoCoupling) bool { return c.missed }) + + BuildIndex(store) + BeginRepoTopologyMutation(store, "b")(true, []string{"b/b.go"}) + if reads := store.reads.Load(); reads != 1 { + t.Fatalf("the coupling was read %d times, want once", reads) + } + if _, _, _, hit, _ := LookupCached(store, seedA); hit { + t.Fatal("a mutation of a store with no coupling kept the records of another repository") + } +} From 5d45aa719a8011b6c62d4616786196073e661b91 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 21:08:03 +0100 Subject: [PATCH 008/138] indexer: guard reach records against a cross-repository patch A patch of one repository that binds a call into another changes the reach of the callee's repository too. Pin that the record published before the patch is not served afterwards, so narrowing the topology writer to the patched repository cannot leave the other one stale. --- internal/indexer/repo_topology_test.go | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 internal/indexer/repo_topology_test.go diff --git a/internal/indexer/repo_topology_test.go b/internal/indexer/repo_topology_test.go new file mode 100644 index 000000000..4a9726c7d --- /dev/null +++ b/internal/indexer/repo_topology_test.go @@ -0,0 +1,77 @@ +package indexer + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/reach" + "github.com/zzet/gortex/internal/search" +) + +// repoTopologyFixture tracks two Go repositories in one SQLite store with no +// edge between them: "lib" (module example.org/library) and "app". +func repoTopologyFixture(t *testing.T) (*MultiIndexer, graph.Store, map[string]string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + lib, app := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(lib, "go.mod"), "module example.org/library\n") + writeFile(t, filepath.Join(lib, "lib.go"), "package library\nfunc Hello() {}\nfunc Local() { Hello() }\n") + writeFile(t, filepath.Join(app, "go.mod"), "module example.org/app\n") + writeFile(t, filepath.Join(app, "main.go"), "package app\nfunc Run() {}\nfunc Start() { Run() }\n") + + s, err := store_sqlite.Open(filepath.Join(t.TempDir(), "graph.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + mi := NewMultiIndexer(s, newTestRegistry(), search.NewNull(), newTestConfigManager(t), zap.NewNop()) + t.Cleanup(func() { require.NoError(t, mi.Close(context.Background())) }) + for _, entry := range []config.RepoEntry{ + {Path: lib, Name: "lib", Workspace: "shared"}, + {Path: app, Name: "app", Workspace: "shared"}, + } { + _, err := mi.TrackRepoCtx(t.Context(), entry) + require.NoError(t, err) + } + return mi, s, map[string]string{"lib": lib, "app": app} +} + +func reachIDs(entries []reach.Entry) []string { + ids := make([]string, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.ID) + } + return ids +} + +// A patch in one repository that adds an edge into another must retire the +// other repository's reach records too, or impact keeps answering from a +// record that misses the new caller. +func TestCrossRepoPatchInvalidatesBothRepos(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + reach.BuildIndex(g) + d1, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") + require.True(t, hit, "fixture must start from a published record") + require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) + + main := filepath.Join(roots["app"], "main.go") + writeFile(t, main, "package app\nimport library \"example.org/library\"\nfunc Run() {}\nfunc Start() { Run(); library.Hello() }\n") + _, err := mi.IncrementalReindexRepo("app", []string{main}) + require.NoError(t, err) + var bound bool + for _, e := range g.GetOutEdges("app/main.go::Start") { + bound = bound || e.To == "lib/lib.go::Hello" + } + require.True(t, bound, "the patch must bind the cross-repository call") + + d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") + require.True(t, hit) + require.False(t, truncated) + require.ElementsMatch(t, []string{"lib/lib.go::Local", "app/main.go::Start"}, reachIDs(d1), + "the record published before the patch was served") +} From 4bae951e3fad44600cb09a38ef9ec4e0a7b3405e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:07:43 +0100 Subject: [PATCH 009/138] mcp: resolve a symbol's range against the bytes being read A symbol's stored line range describes the bytes the file held when it was last indexed. Between an edit and the reindex that follows it, a reader that slices that range out of the current file serves the wrong text, and an editor that matches a fragment inside it can land in a neighbouring symbol. nodeLinesIn trusts the stored range only while the index records exactly the caller's bytes and its graph still holds that range: a reindex writes the nodes before the content identity, and the caller may have read the node before it committed. Otherwise it parses the bytes once and locates the symbol again by its order among the declarations whose ID shares its base. An ID with no position in it is unique, so that is an exact match. A redeclared init's _L, an object member's @ and a local binding's @+ are assigned by position, so they pair only while the number of such declarations holds; errSymbolAmbiguous refuses the rest rather than serve a neighbour, and errSymbolRemoved reports a symbol that is gone. readNodeSource reads a file once and resolves the range against what it read, keeping editor overlays on the overlay view's own range. Refs #1014 --- internal/mcp/node_source.go | 126 +++++++++++++++++++++++++++++ internal/mcp/node_source_test.go | 135 +++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 internal/mcp/node_source.go create mode 100644 internal/mcp/node_source_test.go diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go new file mode 100644 index 000000000..f42565e04 --- /dev/null +++ b/internal/mcp/node_source.go @@ -0,0 +1,126 @@ +package mcp + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +// errSymbolRemoved reports that a symbol the graph still lists is no longer +// declared in the file's current bytes: an edit removed it before the index +// caught up. +var errSymbolRemoved = errors.New("symbol was removed from the file since it was indexed") + +// errSymbolAmbiguous reports that a symbol's ID was derived from its position +// and declarations sharing that ID were added or removed since the file was +// indexed, so the current bytes cannot say which of them it is. +var errSymbolAmbiguous = errors.New("symbol cannot be told apart from same-named declarations added or removed since the file was indexed") + +// nodeLinesIn returns node's 1-based line range within content, the bytes +// the caller read for node's file. The stored range is used only while the +// index records exactly these bytes and still holds that range; otherwise the +// bytes are parsed once and the symbol is located again, so a range read from +// one version of a file is never applied to another. +func (s *Server) nodeLinesIn(ctx context.Context, node *graph.Node, content []byte) (int, int, error) { + owner, rel := s.indexerForRel(node.FilePath) + if owner == nil || rel == "" { + return 0, 0, fmt.Errorf("no indexer owns %s", node.FilePath) + } + if owner.IndexedContentMatches(rel, content) { + // A reindex writes a file's nodes before its content identity, so + // the node read now belongs to these bytes. node may have been read + // before that reindex committed. + if live := owner.Graph().GetNode(node.ID); live != nil && + live.StartLine == node.StartLine && live.EndLine == node.EndLine { + return node.StartLine, node.EndLine, nil + } + } + _, local, ok := strings.Cut(node.ID, "::") + if !ok { + return 0, 0, fmt.Errorf("symbol %s has no file-local id", node.ID) + } + extracted, err := owner.ExtractSource(ctx, rel, content) + if err != nil { + return 0, 0, fmt.Errorf("locate %s in the current file: %w", node.ID, err) + } + defer extracted.ReleaseTree() + // A declaration is located by its order among the declarations that + // share its ID base, as the reader that produced node listed them. An + // ID with no position in it is unique, so that is an exact match; a + // positional one pairs only while the count holds. + base := declarationBase(local) + current := declarationsSharing(extracted.Nodes, base) + if len(current) == 0 { + return 0, 0, errSymbolRemoved + } + stored := declarationsSharing(s.readerFor(ctx).GetFileNodes(node.FilePath), base) + at := slices.IndexFunc(stored, func(n *graph.Node) bool { return n.ID == node.ID }) + if at < 0 || len(stored) != len(current) { + return 0, 0, fmt.Errorf("%w: %s", errSymbolAmbiguous, node.ID) + } + return current[at].StartLine, current[at].EndLine, nil +} + +// declarationBase strips the position an extractor appends to a file-local +// ID to keep same-named declarations apart: _L on a redeclaration +// (the first keeps the bare ID), @ on an object member or anonymous +// class, and @+ on a local binding. +func declarationBase(local string) string { + trimmed := strings.TrimRight(local, "0123456789") + if len(trimmed) == len(local) { + return local + } + for _, marker := range []string{"_L", "@+", "@"} { + if base, ok := strings.CutSuffix(trimmed, marker); ok { + return base + } + } + return local +} + +// declarationsSharing returns the nodes whose file-local ID has base, in +// source order, keeping one node per ID. +func declarationsSharing(nodes []*graph.Node, base string) []*graph.Node { + var out []*graph.Node + for _, n := range nodes { + if _, local, ok := strings.Cut(n.ID, "::"); ok && declarationBase(local) == base { + out = append(out, n) + } + } + slices.SortFunc(out, func(a, b *graph.Node) int { + return cmp.Or(cmp.Compare(a.StartLine, b.StartLine), strings.Compare(a.ID, b.ID)) + }) + return slices.CompactFunc(out, func(a, b *graph.Node) bool { return a.ID == b.ID }) +} + +// nodeSource is a node's current line range and the content it indexes into. +type nodeSource struct { + content string // UTF-16 sources decoded, as the index reads them + startLine int + endLine int +} + +// readNodeSource reads the file at absPath that declares node and resolves +// node's range against exactly the bytes it read. An editor overlay is served +// with the node's own range, since the overlay view parsed that content. +func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath string) (nodeSource, error) { + if content, ok := s.overlayContentFor(ctx, absPath); ok { + return nodeSource{content: content, startLine: node.StartLine, endLine: node.EndLine}, nil + } + raw, err := os.ReadFile(absPath) + if err != nil { + return nodeSource{}, err + } + start, end, err := s.nodeLinesIn(ctx, node, raw) + if err != nil { + return nodeSource{}, err + } + return nodeSource{content: string(indexer.DecodeUTF16Source(raw)), startLine: start, endLine: end}, nil +} diff --git a/internal/mcp/node_source_test.go b/internal/mcp/node_source_test.go new file mode 100644 index 000000000..d0524e597 --- /dev/null +++ b/internal/mcp/node_source_test.go @@ -0,0 +1,135 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// shiftTestServerMain inserts two lines above main in the setupTestServer +// fixture without reindexing, moving helper from line 11 to line 13. The old +// range then covers main's closing brace. +func shiftTestServerMain(t *testing.T, dir string) { + t.Helper() + path := filepath.Join(dir, "main.go") + src, err := os.ReadFile(path) + require.NoError(t, err) + shifted := strings.Replace(string(src), "func main() {", "// one\n// two\nfunc main() {", 1) + require.NoError(t, os.WriteFile(path, []byte(shifted), 0o644)) +} + +func TestReadNodeSourceResolvesCurrentLines(t *testing.T) { + srv, dir := setupTestServer(t) + ctx := context.Background() + mainGo := filepath.Join(dir, "main.go") + stored := srv.engineFor(ctx).GetSymbol("main.go::helper") + require.NotNil(t, stored) + require.Equal(t, 11, stored.StartLine) + // The handler's copy, which a later reindex does not update. + node := *stored + + read := func() (nodeSource, uint64, error) { + before := parser.ParseCount() + src, err := srv.readNodeSource(ctx, &node, mainGo) + return src, parser.ParseCount() - before, err + } + + src, parses, err := read() + require.NoError(t, err) + require.Equal(t, [2]int{11, 11}, [2]int{src.startLine, src.endLine}) + require.Zero(t, parses, "bytes the index recorded reuse the stored range without parsing") + + shiftTestServerMain(t, dir) + src, parses, err = read() + require.NoError(t, err) + require.Equal(t, [2]int{13, 13}, [2]int{src.startLine, src.endLine}) + require.Equal(t, "func helper() {}", strings.Split(src.content, "\n")[src.startLine-1]) + require.EqualValues(t, 1, parses, "unrecorded bytes are parsed once to locate the symbol") + + // A reindex that commits after the handler read node records these + // bytes, so they match the index while node still holds the old range. + require.NoError(t, srv.indexer.IndexFile(mainGo)) + src, _, err = read() + require.NoError(t, err) + require.Equal(t, [2]int{13, 13}, [2]int{src.startLine, src.endLine}) + require.Equal(t, "func helper() {}", strings.Split(src.content, "\n")[src.startLine-1]) + + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n\nfunc main() {}\n"), 0o644)) + _, _, err = read() + require.ErrorIs(t, err, errSymbolRemoved) +} + +// TestReadNodeSourcePairsPositionalIDs covers IDs derived from where a +// declaration sits: a redeclared Go init gains _L while the first keeps +// the bare ID, and a TypeScript object member carries @. +func TestReadNodeSourcePairsPositionalIDs(t *testing.T) { + srv, dir := setupTestServer(t) + ctx := context.Background() + write := func(name, src string) string { + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) + return path + } + symbol := func(id string) *graph.Node { + node := srv.engineFor(ctx).GetSymbol(id) + require.NotNil(t, node, id) + return node + } + firstLine := func(node *graph.Node, path string) (string, error) { + src, err := srv.readNodeSource(ctx, node, path) + if err != nil { + return "", err + } + return strings.Split(src.content, "\n")[src.startLine-1], nil + } + + const inits = "package main\n\nfunc init() { first() }\n\nfunc init() { second() }\n" + initsGo := write("inits.go", inits) + require.NoError(t, srv.indexer.IndexFile(initsGo)) + first, second := symbol("inits.go::init"), symbol("inits.go::init_L5") + + write("inits.go", strings.Replace(inits, "package main\n", "package main\n\n// shifted\n", 1)) + for node, want := range map[*graph.Node]string{first: "func init() { first() }", second: "func init() { second() }"} { + got, err := firstLine(node, initsGo) + require.NoError(t, err, node.ID) + require.Equal(t, want, got, node.ID) + } + + // A third init above the others takes the bare ID and renumbers the rest, + // so neither ID nor position says which stored init is which. + write("inits.go", strings.Replace(inits, "package main\n", "package main\n\nfunc init() { third() }\n", 1)) + for _, node := range []*graph.Node{first, second} { + _, err := firstLine(node, initsGo) + require.ErrorIs(t, err, errSymbolAmbiguous, node.ID) + } + + const members = "export const handlers = {\n onClick: () => 1,\n};\n" + membersTS := write("members.ts", members) + require.NoError(t, srv.indexer.IndexFile(membersTS)) + onClick := symbol("members.ts::handlers.onClick@2") + write("members.ts", "// one\n// two\n"+members) + got, err := firstLine(onClick, membersTS) + require.NoError(t, err) + require.Equal(t, " onClick: () => 1,", got) +} + +func TestDeclarationBase(t *testing.T) { + for local, want := range map[string]string{ + "init": "init", + "init_L12": "init", + "v2_L7": "v2", + "handlers.onClick@4": "handlers.onClick", + "Run#local:err@+3": "Run#local:err", + "Base64": "Base64", + "Point.X": "Point.X", + } { + require.Equal(t, want, declarationBase(local), local) + } +} From 69ccf0b180d6144fe42ee5c3f69b82ad2055cb4e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:08:21 +0100 Subject: [PATCH 010/138] mcp: test get_symbol_source after an unindexed line shift In single-repo mode with a watcher running, get_symbol_source trusts the stored line range of a file the watcher has not reindexed yet, so after an edit that shifts lines it cuts the old range out of the new bytes and serves the wrong text. The test edits the fixture above the symbol without reindexing and asks for the symbol's current body and range. It fails until the handler reads through readNodeSource. Refs #1014 --- internal/mcp/symbol_source_line_shift_test.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 internal/mcp/symbol_source_line_shift_test.go diff --git a/internal/mcp/symbol_source_line_shift_test.go b/internal/mcp/symbol_source_line_shift_test.go new file mode 100644 index 000000000..90907a764 --- /dev/null +++ b/internal/mcp/symbol_source_line_shift_test.go @@ -0,0 +1,25 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSymbolSourceSingleRepoWatcherLineShift reads a symbol whose file was +// edited, shifting its lines, while the watcher that owns freshness has not +// reindexed the file yet. The answer must be the symbol's current body and +// range, never the old range cut out of the new bytes. +func TestSymbolSourceSingleRepoWatcherLineShift(t *testing.T) { + srv, dir := setupTestServer(t) + srv.SetWatcher(mutationTestWatcher{}) + shiftTestServerMain(t, dir) + + resp := extractTextResult(t, callTool(t, srv, "get_symbol_source", map[string]any{ + "id": "main.go::helper", + "context_lines": float64(0), + })) + require.Equal(t, "func helper() {}", resp["source"]) + require.EqualValues(t, 13, resp["start_line"]) + require.EqualValues(t, 13, resp["end_line"]) +} From 733ea766426a1352f46514b393caa0fcc5072799 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:12:40 +0100 Subject: [PATCH 011/138] mcp: locate a symbol edit inside its resolved range edit_symbol, the plain batch and the transactional batch each carry a copy of the same matching code: find old_source in the symbol's lines, widen the region over the doc comment above, then turn line offsets into byte offsets. The batch copies also fall back to a whole-file match when the range looks stale, which is how a fragment repeated in two functions can be applied to the wrong one. locateSymbolEdit is that matching once, over a range that nodeLinesIn resolved against the same bytes. It requires the fragment to occur exactly once in the region it matches and has no whole-file fallback, because a resolved range is never stale. Refs #1012 --- internal/mcp/symbol_span.go | 71 ++++++++++++++++++++++++ internal/mcp/symbol_span_test.go | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 internal/mcp/symbol_span.go create mode 100644 internal/mcp/symbol_span_test.go diff --git a/internal/mcp/symbol_span.go b/internal/mcp/symbol_span.go new file mode 100644 index 000000000..b3dea5816 --- /dev/null +++ b/internal/mcp/symbol_span.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "errors" + "strings" +) + +var ( + errSymbolRangeOutsideFile = errors.New("symbol line range exceeds file length") + errEditFragmentNotFound = errors.New("old_source not found within the symbol") + errEditFragmentRepeated = errors.New("old_source appears more than once within the symbol; provide a larger fragment") +) + +// symbolEdit locates old_source inside a symbol: byte offsets into the file +// content, and whether the match bridged a line-ending difference, in which +// case the replacement must adopt the region's own terminators. +type symbolEdit struct { + start, end int + normalized bool +} + +// locateSymbolEdit finds oldSource inside the symbol on lines +// [startLine, endLine] of content: within the symbol itself, or else within +// the symbol plus the comment and blank lines directly above it, since agents +// often quote a symbol's doc comment. The fragment must occur exactly once in +// the region it matches, so a fragment repeated elsewhere in the file never +// pulls the edit into another symbol. The range must describe content: take +// it from nodeLinesIn over the same bytes, never from a stored node alone. +func locateSymbolEdit(content string, startLine, endLine int, oldSource string) (symbolEdit, error) { + lines := strings.Split(content, "\n") + if startLine < 1 || endLine < startLine || endLine > len(lines) { + return symbolEdit{}, errSymbolRangeOutsideFile + } + offsets := make([]int, endLine) + for i := 1; i < endLine; i++ { + offsets[i] = offsets[i-1] + len(lines[i-1]) + 1 + } + end := offsets[endLine-1] + len(lines[endLine-1]) + from := startLine - 1 + matches := findEOLMatches(content[offsets[from]:end], oldSource) + if matches.count == 0 { + above := from + for above > 0 && isDocOrBlankLine(lines[above-1]) { + above-- + } + if above < from { + from = above + matches = findEOLMatches(content[offsets[from]:end], oldSource) + } + } + switch { + case matches.count == 0: + return symbolEdit{}, errEditFragmentNotFound + case matches.count > 1: + return symbolEdit{}, errEditFragmentRepeated + } + span := matches.spans[0] + return symbolEdit{ + start: offsets[from] + span.start, + end: offsets[from] + span.end, + normalized: matches.normalized, + }, nil +} + +// isDocOrBlankLine reports whether line can belong to the doc comment block +// above a declaration. +func isDocOrBlankLine(line string) bool { + trimmed := strings.TrimSpace(line) + return trimmed == "" || strings.HasPrefix(trimmed, "//") || + strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") +} diff --git a/internal/mcp/symbol_span_test.go b/internal/mcp/symbol_span_test.go new file mode 100644 index 000000000..1a7145c44 --- /dev/null +++ b/internal/mcp/symbol_span_test.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +// repeatedFragmentSource has the same body in two functions, so a fragment +// matched against the wrong range edits the wrong function. +const repeatedFragmentSource = "package sample\n\nfunc first() error {\n\treturn nil\n}\n\n// second documents itself.\nfunc second() error {\n\treturn nil\n}\n" + +func TestLocateSymbolEdit(t *testing.T) { + src := repeatedFragmentSource + secondAt := strings.Index(src, "func second") + crlf := strings.ReplaceAll(src, "\n", "\r\n") + + cases := []struct { + name string + content string + start, end int + fragment string + want string // the bytes the span covers + after int // the span starts at or after this offset + normalized bool + err error + }{ + {name: "first", content: src, start: 3, end: 5, fragment: "return nil", want: "return nil"}, + {name: "second", content: src, start: 8, end: 10, fragment: "return nil", want: "return nil", after: secondAt}, + {name: "doc comment above", content: src, start: 8, end: 10, fragment: "// second documents itself.\nfunc second", want: "// second documents itself.\nfunc second"}, + {name: "crlf region", content: crlf, start: 8, end: 10, fragment: "error {\n\treturn nil", want: "error {\r\n\treturn nil", normalized: true}, + {name: "repeated inside the symbol", content: "package p\n\nfunc both() {\n\tx++\n\tx++\n}\n", start: 3, end: 6, fragment: "x++", err: errEditFragmentRepeated}, + {name: "outside the symbol", content: src, start: 3, end: 5, fragment: "func second", err: errEditFragmentNotFound}, + {name: "range past the end", content: src, start: 8, end: 20, fragment: "return nil", err: errSymbolRangeOutsideFile}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := locateSymbolEdit(tc.content, tc.start, tc.end, tc.fragment) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, tc.content[got.start:got.end]) + require.GreaterOrEqual(t, got.start, tc.after) + require.Equal(t, tc.normalized, got.normalized) + }) + } +} + +// TestSymbolEditFollowsLineShiftPastRepeatedFragment covers the edit that +// lands in the wrong function: another session's edit shifted the file, so +// the stored range of second now covers first's body, where the fragment +// also occurs exactly once. Resolving the range against the bytes read under +// the lock keeps the edit in second. +func TestSymbolEditFollowsLineShiftPastRepeatedFragment(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.go") + src := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + ctx := context.Background() + + node := srv.engineFor(ctx).GetSymbol("sample.go::second") + require.NotNil(t, node) + require.Equal(t, [2]int{7, 9}, [2]int{node.StartLine, node.EndLine}) + + shifted := strings.Replace(src, "func first", "// a\n// b\n// c\nfunc first", 1) + secondAt := strings.Index(shifted, "func second") + stale, err := locateSymbolEdit(shifted, node.StartLine, node.EndLine, "\treturn nil") + require.NoError(t, err) + require.Less(t, stale.start, secondAt, "the stored range alone lands the edit in first") + + start, end, err := srv.nodeLinesIn(ctx, node, []byte(shifted)) + require.NoError(t, err) + require.Equal(t, [2]int{10, 12}, [2]int{start, end}) + edit, err := locateSymbolEdit(shifted, start, end, "\treturn nil") + require.NoError(t, err) + require.Greater(t, edit.start, secondAt) +} From 4d2bb4229d9e24f3ce6b21ef21e76704e8ee193e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:12:40 +0100 Subject: [PATCH 012/138] mcp: report source writers that bypass the choke point Some writers change source files without the mutation path lock or a commit record (move and inline, overlay merge, safe-delete propagate, the transactional batch), so a concurrent edit to the same file can be lost. Before they are routed through one write choke point, list them. The census walks the package's non-test files and reports every reference, called or passed as a value, to a file writer outside the choke point and a short list of writers of non-source outputs. It only reports for now and becomes a gate once every writer is routed. Refs #1012 --- internal/mcp/source_write_census_test.go | 143 +++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 internal/mcp/source_write_census_test.go diff --git a/internal/mcp/source_write_census_test.go b/internal/mcp/source_write_census_test.go new file mode 100644 index 000000000..510a7a677 --- /dev/null +++ b/internal/mcp/source_write_census_test.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// sourceWriteChokePoint is the one function allowed to write or remove source +// files in this package: it refuses dead requests and records the commit, so +// every source write is ordered against the other writers of the same file. +const sourceWriteChokePoint = "Server.commitFileMutation" + +// sourceWriteCensusEnforced turns the census from a report into a gate once +// every source writer routes through the choke point. +const sourceWriteCensusEnforced = false + +// nonSourceWriters may reference a file writer without the choke point: they +// write tool outputs, transaction journals and spill files, or they are a +// write primitive themselves (references to those still count). +var nonSourceWriters = map[string]string{ + "durableAtomicWriteFile": "the durable write primitive", + "Server.persistBatchManifest": "batch transaction manifest", + "Server.prepareBatchJournal": "batch transaction backups", + "Server.rollbackBatchReceipt": "restores a failed batch from its backups", + "symbolPageStorage.close": "symbol page spill directory", + "symbolPageStorage.spill": "symbol page spill directory", + "Server.handleExportGraph": "export output path", + "Server.handleGenerateSkill": "skill files", + "writeWikiFile": "wiki output", +} + +// sourceWriteSink names the file writer n refers to, called or passed as a +// value, or returns "". +func sourceWriteSink(n ast.Node) string { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return "" + } + switch x := sel.X.(type) { + case *ast.Ident: + switch { + case x.Name == "os" && (sel.Sel.Name == "WriteFile" || sel.Sel.Name == "Remove"): + return "os." + sel.Sel.Name + case x.Name == "agents" && sel.Sel.Name == "AtomicWriteFile": + return "agents.AtomicWriteFile" + } + case *ast.CallExpr: + if inner, ok := x.Fun.(*ast.SelectorExpr); ok && inner.Sel.Name == "batchDurability" && sel.Sel.Name == "writeFile" { + return "batchDurability().writeFile" + } + } + return "" +} + +// funcKey names a declaration as Type.method or func. +func funcKey(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return fn.Name.Name + } + recv := fn.Recv.List[0].Type + if star, ok := recv.(*ast.StarExpr); ok { + recv = star.X + } + if ident, ok := recv.(*ast.Ident); ok { + return ident.Name + "." + fn.Name.Name + } + return fn.Name.Name +} + +// sourceWriteBypasses lists every file-writer reference made outside the +// choke point and the non-source writers, as "file: func -> sink". +func sourceWriteBypasses(files map[string]*ast.File) []string { + var out []string + for name, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + key := funcKey(fn) + if _, allowed := nonSourceWriters[key]; allowed || key == sourceWriteChokePoint { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if sink := sourceWriteSink(n); sink != "" { + out = append(out, fmt.Sprintf("%s: %s -> %s", name, key, sink)) + } + return true + }) + } + } + sort.Strings(out) + return out +} + +// TestSourceWriteChokePointCensus lists the source writers that bypass the +// choke point. It reports until every writer is routed, then gates. +func TestSourceWriteChokePointCensus(t *testing.T) { + paths, err := filepath.Glob("*.go") + require.NoError(t, err) + fset := token.NewFileSet() + files := make(map[string]*ast.File) + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + require.NoError(t, err) + files[path] = file + } + bypasses := sourceWriteBypasses(files) + for _, bypass := range bypasses { + t.Log("source write outside the choke point: " + bypass) + } + if sourceWriteCensusEnforced { + require.Empty(t, bypasses, "route these writers through %s", sourceWriteChokePoint) + } + + // The census must see a writer passed as a value as well as a call, and + // must not count the choke point or its callers. + fixture, err := parser.ParseFile(fset, "fixture.go", `package mcp +func (s *Server) commitFileMutation() { agents.AtomicWriteFile() } +func (s *Server) handleRouted() { s.commitFileMutation() } +func (s *Server) handleFixture() { + write := s.batchDurability().writeFile + _ = write + os.Remove("x") +} +`, parser.SkipObjectResolution) + require.NoError(t, err) + require.Equal(t, []string{ + "fixture.go: Server.handleFixture -> batchDurability().writeFile", + "fixture.go: Server.handleFixture -> os.Remove", + }, sourceWriteBypasses(map[string]*ast.File{"fixture.go": fixture})) +} From f0a051f15bc8d0a2aa55f28466203a5b742fc175 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 17:12:40 +0100 Subject: [PATCH 013/138] mcp: test that a primary edit announces no write intent Every source-mutating request announces store write intent for its whole duration, although a primary-checkout edit in watcher mode writes only the source file and the watcher's patch announces its own store writes. While the intent is held, WAL reclaim stands down. The test spies on the store across an edit_file in watcher mode and fails while the request arms write intent; it passes once intent is armed only on paths that write the store. Refs #1012 --- .../primary_publication_write_intent_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/mcp/primary_publication_write_intent_test.go b/internal/mcp/primary_publication_write_intent_test.go index 0a34b4464..8bdb54ea2 100644 --- a/internal/mcp/primary_publication_write_intent_test.go +++ b/internal/mcp/primary_publication_write_intent_test.go @@ -10,9 +10,11 @@ import ( "testing" "time" + mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" ) @@ -211,3 +213,35 @@ func TestPrimaryMutationFallbackKeepsOrdinaryWriteIntent(t *testing.T) { }) } } + +// TestPrimaryEditAnnouncesNoWriteIntent spies on the store across a primary +// checkout edit_file in watcher mode. The edit writes only the source file +// and the watcher's patch announces its own store writes, so nothing on the +// ack path may announce a writer that holds off WAL reclaim for the request. +func TestPrimaryEditAnnouncesNoWriteIntent(t *testing.T) { + t.Setenv("GORTEX_SQLITE_LAZY_INDEXES", "off") + t.Setenv("GORTEX_SQLITE_WAL_RECLAIM_MB", "0") + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "intent.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + srv, dir := setupTestServer(t) + srv.materializer = &graphview.Materializer{Store: store} + require.True(t, srv.facades.mutatesSource("edit_file")) + mainGo := filepath.Join(dir, "main.go") + done := make(chan indexer.MutationResult, 1) + done <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(done) + w := &publicationIntentWatcher{watcherHistory: mutationTestWatcher{}, store: store, + tickets: map[string]*indexer.MutationTicket{mainGo: {Path: mainGo, Generation: 1, Done: done}}} + srv.SetWatcher(w) + + ctx, release := srv.withSourceMutationWriteIntent(t.Context(), "edit_file") + defer release() + require.False(t, store.WriteWanted(), "the edit request announced a store writer") + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{"path": mainGo, "old_string": "func helper() {}", "new_string": "func helper() { _ = 1 }"} + res, err := srv.handleEditFile(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError, "edit refused: %v", res.Content) + require.False(t, w.protected.Load(), "watcher admission ran under an announced store writer") +} From 1bdba007d86b53054fe4d26367d31a39e2f5692f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 19:25:27 +0100 Subject: [PATCH 014/138] mcp: pin search_symbols and search_text output with goldens Search is about to read through files an edit has just written, before their reindex lands. With nothing pending its answers must not move by a byte, and today nothing would notice if they did. Pin what search_symbols and search_text return on a small fixture: exact, prefix, concept, kind, limit and no-match symbol queries, the json, compact, toon and gcx modes, and literal, limit, path, regexp and no-match text queries. The checkout's temporary root reads as $ROOT and the page cursor, minted at random per call, as $CURSOR; nothing else is rewritten. The server runs over a SQLite store with the indexer's search provider, as the daemon wires it, so symbol queries rank through the store's full-text index. A bare engine over an in-memory graph has no search backend and takes the substring fallback, which would leave the ranking path these goldens guard unpinned. The goldens were generated before any of that work; regenerate with -update-search-golden only from a tree whose search output is the reference. --- internal/mcp/search_golden_test.go | 165 ++++++++++++++++++ .../search_golden/symbols_compact.txt | 2 + .../search_golden/symbols_concept.txt | 1 + .../testdata/search_golden/symbols_exact.txt | 1 + .../testdata/search_golden/symbols_gcx.txt | 4 + .../testdata/search_golden/symbols_json.txt | 1 + .../testdata/search_golden/symbols_kind.txt | 1 + .../testdata/search_golden/symbols_limit.txt | 1 + .../search_golden/symbols_missing.txt | 1 + .../testdata/search_golden/symbols_prefix.txt | 1 + .../testdata/search_golden/symbols_toon.txt | 5 + .../mcp/testdata/search_golden/text_limit.txt | 1 + .../testdata/search_golden/text_literal.txt | 1 + .../testdata/search_golden/text_missing.txt | 1 + .../mcp/testdata/search_golden/text_path.txt | 1 + .../testdata/search_golden/text_regexp.txt | 1 + 16 files changed, 188 insertions(+) create mode 100644 internal/mcp/search_golden_test.go create mode 100644 internal/mcp/testdata/search_golden/symbols_compact.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_concept.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_exact.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_gcx.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_json.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_kind.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_limit.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_missing.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_prefix.txt create mode 100644 internal/mcp/testdata/search_golden/symbols_toon.txt create mode 100644 internal/mcp/testdata/search_golden/text_limit.txt create mode 100644 internal/mcp/testdata/search_golden/text_literal.txt create mode 100644 internal/mcp/testdata/search_golden/text_missing.txt create mode 100644 internal/mcp/testdata/search_golden/text_path.txt create mode 100644 internal/mcp/testdata/search_golden/text_regexp.txt diff --git a/internal/mcp/search_golden_test.go b/internal/mcp/search_golden_test.go new file mode 100644 index 000000000..7e35010fa --- /dev/null +++ b/internal/mcp/search_golden_test.go @@ -0,0 +1,165 @@ +package mcp + +import ( + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +var updateSearchGolden = flag.Bool("update-search-golden", false, "rewrite the search_symbols / search_text goldens") + +// searchGoldenCursor matches a page cursor, which names a sequence minted at +// random on every call. +var searchGoldenCursor = regexp.MustCompile(`"next_cursor":"[A-Za-z0-9_-]+"`) + +// searchGoldenFixture is a small module whose names share prefixes, so exact, +// prefix and concept queries rank different candidate sets, and whose bodies +// repeat literals across files, so text queries hit several paths. +var searchGoldenFixture = map[string]string{ + "go.mod": "module example.com/app\n\ngo 1.22\n", + "config/config.go": `package config + +// Config holds the server settings. +type Config struct { + Port int + Host string +} + +// LoadConfig reads the settings file at path. +func LoadConfig(path string) (*Config, error) { + return &Config{Port: 8080, Host: "localhost"}, nil +} + +// Validate reports whether the settings are usable. +func (c *Config) Validate() error { return nil } +`, + "server/server.go": `package server + +import "example.com/app/config" + +// Server answers requests. +type Server struct { + cfg *config.Config +} + +// NewServer builds a server from cfg. +func NewServer(cfg *config.Config) *Server { return &Server{cfg: cfg} } + +// HandleRequest serves one request. +func (s *Server) HandleRequest(path string) string { + if err := s.cfg.Validate(); err != nil { + return "invalid config" + } + return "handled " + path +} + +// HandleHealth answers the health probe. +func (s *Server) HandleHealth() string { return "handled health" } +`, + "main.go": `package main + +import ( + "example.com/app/config" + "example.com/app/server" +) + +func main() { + cfg, _ := config.LoadConfig("app.yaml") + srv := server.NewServer(cfg) + _ = srv.HandleRequest("/") + _ = srv.HandleHealth() +} +`, +} + +// TestSearchOutputsMatchGoldens pins the exact bytes search_symbols and +// search_text answer with on a quiet checkout, in every output mode. Work that +// lets search read through just-edited files must leave this path untouched: +// with nothing pending, the answer is byte-for-byte what it was before. The +// only rewrites are the checkout's temporary root, which reads as $ROOT, and +// the random page cursor, which reads as $CURSOR. +// +// Regenerate with -update-search-golden, on a tree whose search code is the +// reference. +func TestSearchOutputsMatchGoldens(t *testing.T) { + dir := t.TempDir() + for rel, body := range searchGoldenFixture { + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) + } + // The store and engine are wired as the daemon wires them, so symbol + // queries rank through the store's full-text index, not the substring + // fallback a bare engine takes. + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "search.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + idx := indexer.New(store, testRegistry(), config.Default().Index, zap.NewNop()) + _, err = idx.Index(dir) + require.NoError(t, err) + requireSymbolFTS(t, store) + eng := query.NewEngine(store) + eng.SetSearchProvider(idx.Search) + srv := NewServer(eng, store, idx, nil, zap.NewNop(), nil) + srv.RunAnalysis() + // The resolved spelling goes first: on macOS it is the temporary root + // with a /private prefix. + roots := []string{dir} + if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { + roots = []string{resolved, dir} + } + + cases := []struct { + name string + tool string + args map[string]any + }{ + {"symbols_exact", "search_symbols", map[string]any{"query": "HandleRequest"}}, + {"symbols_prefix", "search_symbols", map[string]any{"query": "Handle"}}, + {"symbols_concept", "search_symbols", map[string]any{"query": "load settings config"}}, + {"symbols_limit", "search_symbols", map[string]any{"query": "Handle", "limit": 1}}, + {"symbols_kind", "search_symbols", map[string]any{"query": "Config", "kind": "type"}}, + {"symbols_missing", "search_symbols", map[string]any{"query": "NoSuchSymbolAnywhere"}}, + {"symbols_json", "search_symbols", map[string]any{"query": "Handle", "format": "json"}}, + {"symbols_compact", "search_symbols", map[string]any{"query": "Handle", "compact": true}}, + {"symbols_toon", "search_symbols", map[string]any{"query": "Handle", "format": "toon"}}, + {"symbols_gcx", "search_symbols", map[string]any{"query": "Handle", "format": "gcx"}}, + {"text_literal", "search_text", map[string]any{"query": "handled"}}, + {"text_limit", "search_text", map[string]any{"query": "config", "limit": 2}}, + {"text_path", "search_text", map[string]any{"query": "config", "path": "server"}}, + {"text_regexp", "search_text", map[string]any{"query": `Handle[A-Z][a-z]+\(`, "regexp": true}}, + {"text_missing", "search_text", map[string]any{"query": "no such literal anywhere"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := callTool(t, srv, tc.tool, tc.args) + require.NotEmpty(t, res.Content) + got := res.Content[0].(mcplib.TextContent).Text + for _, root := range roots { + got = strings.ReplaceAll(got, root, "$ROOT") + } + got = searchGoldenCursor.ReplaceAllLiteralString(got, `"next_cursor":"$CURSOR"`) + golden := filepath.Join("testdata", "search_golden", tc.name+".txt") + if *updateSearchGolden { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, []byte(got), 0o644)) + return + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + require.Equal(t, string(want), got) + }) + } +} diff --git a/internal/mcp/testdata/search_golden/symbols_compact.txt b/internal/mcp/testdata/search_golden/symbols_compact.txt new file mode 100644 index 000000000..735834be7 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_compact.txt @@ -0,0 +1,2 @@ +method Server.HandleRequest server/server.go:14 (in Server) +method Server.HandleHealth server/server.go:22 (in Server) diff --git a/internal/mcp/testdata/search_golden/symbols_concept.txt b/internal/mcp/testdata/search_golden/symbols_concept.txt new file mode 100644 index 000000000..990bf1ed4 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_concept.txt @@ -0,0 +1 @@ +{"query_class":"concept","results":[{"absolute_file_path":"$ROOT/config/config.go","doc":"LoadConfig reads the settings file at path.","file_path":"config/config.go","id":"config/config.go::LoadConfig","kind":"function","name":"LoadConfig","start_line":10,"visibility":"public"},{"absolute_file_path":"$ROOT/config/config.go","doc":"Config holds the server settings.","file_path":"config/config.go","id":"config/config.go::Config","kind":"type","name":"Config","start_line":4,"type_flavor":"struct","visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"NewServer builds a server from cfg.","file_path":"server/server.go","id":"server/server.go::NewServer","kind":"function","name":"NewServer","start_line":11,"visibility":"public"},{"file_path":"","id":"dep::example.com/app/config::LoadConfig","kind":"function","name":"LoadConfig","start_line":0},{"absolute_file_path":"$ROOT/config/config.go","doc":"Validate reports whether the settings are usable.","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Validate","kind":"method","name":"Validate","start_line":15,"visibility":"public"},{"absolute_file_path":"$ROOT/external-call::dep:example.com/app/config","file_path":"external-call::dep:example.com/app/config","id":"external-call::dep::example.com/app/config","kind":"module","name":"example.com/app/config","start_line":0},{"absolute_file_path":"$ROOT/server/server.go","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.cfg","kind":"field","name":"cfg","start_line":7,"visibility":"package"},{"file_path":"","id":"module::go:example.com/app/config","kind":"module","name":"config","start_line":0},{"absolute_file_path":"$ROOT/config/config.go","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Port","kind":"field","name":"Port","start_line":5,"visibility":"public"},{"absolute_file_path":"$ROOT/config/config.go","file_path":"config/config.go","id":"config/config.go::LoadConfig#param:path","kind":"param","name":"path","start_line":10},{"absolute_file_path":"$ROOT/config/config.go","enclosing":"Config","enclosing_id":"config/config.go::Config","file_path":"config/config.go","id":"config/config.go::Config.Host","kind":"field","name":"Host","start_line":6,"visibility":"public"}],"total":11,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_exact.txt b/internal/mcp/testdata/search_golden/symbols_exact.txt new file mode 100644 index 000000000..ef693503e --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_exact.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"}],"total":1,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_gcx.txt b/internal/mcp/testdata/search_golden/symbols_gcx.txt new file mode 100644 index 000000000..c4140762b --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_gcx.txt @@ -0,0 +1,4 @@ +GCX1 tool=search_symbols fields=id,kind,name,path,path_abs,line,sig,enclosing,is_test,test_role,test_runner,type_flavor,ui_component total=2 truncated=false +# 2 result(s) +server/server.go::Server.HandleRequest method Server.HandleRequest server/server.go $ROOT/server/server.go 14 func ((s *Server)) HandleRequest(path string) string Server false +server/server.go::Server.HandleHealth method Server.HandleHealth server/server.go $ROOT/server/server.go 22 func ((s *Server)) HandleHealth() string Server false diff --git a/internal/mcp/testdata/search_golden/symbols_json.txt b/internal/mcp/testdata/search_golden/symbols_json.txt new file mode 100644 index 000000000..1093f71f1 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_json.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleHealth answers the health probe.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleHealth","kind":"method","name":"HandleHealth","start_line":22,"visibility":"public"}],"total":2,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_kind.txt b/internal/mcp/testdata/search_golden/symbols_kind.txt new file mode 100644 index 000000000..879ab0d89 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_kind.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/config/config.go","doc":"Config holds the server settings.","file_path":"config/config.go","id":"config/config.go::Config","kind":"type","name":"Config","start_line":4,"type_flavor":"struct","visibility":"public"}],"total":1,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_limit.txt b/internal/mcp/testdata/search_golden/symbols_limit.txt new file mode 100644 index 000000000..b56450ce4 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_limit.txt @@ -0,0 +1 @@ +{"next_cursor":"$CURSOR","query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"}],"total":2,"truncated":true} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_missing.txt b/internal/mcp/testdata/search_golden/symbols_missing.txt new file mode 100644 index 000000000..0233d92f2 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_missing.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[],"total":0,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_prefix.txt b/internal/mcp/testdata/search_golden/symbols_prefix.txt new file mode 100644 index 000000000..1093f71f1 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_prefix.txt @@ -0,0 +1 @@ +{"query_class":"symbol","results":[{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleRequest serves one request.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleRequest","kind":"method","name":"HandleRequest","start_line":14,"visibility":"public"},{"absolute_file_path":"$ROOT/server/server.go","doc":"HandleHealth answers the health probe.","enclosing":"Server","enclosing_id":"server/server.go::Server","file_path":"server/server.go","id":"server/server.go::Server.HandleHealth","kind":"method","name":"HandleHealth","start_line":22,"visibility":"public"}],"total":2,"truncated":false} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/symbols_toon.txt b/internal/mcp/testdata/search_golden/symbols_toon.txt new file mode 100644 index 000000000..3960b6123 --- /dev/null +++ b/internal/mcp/testdata/search_golden/symbols_toon.txt @@ -0,0 +1,5 @@ +results[2]{id,kind,name,file_path,start_line,enclosing,enclosing_id,is_test,test_role}: + "server/server.go::Server.HandleRequest",method,HandleRequest,server/server.go,14,Server,"server/server.go::Server",false,"" + "server/server.go::Server.HandleHealth",method,HandleHealth,server/server.go,22,Server,"server/server.go::Server",false,"" +total: 2 +truncated: false \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_limit.txt b/internal/mcp/testdata/search_golden/text_limit.txt new file mode 100644 index 000000000..3c40f5796 --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_limit.txt @@ -0,0 +1 @@ +{"_limit_applied":2,"_truncated_by_limit":true,"count":2,"count_is_exact":false,"matches":[{"path":"config/config.go","line":1,"text":"package config"},{"path":"main.go","line":4,"text":"\t\"example.com/app/config\""}],"query":"config","truncation_note":"the search stopped at `limit`, so `count` is a floor rather than a total and the matches are a prefix of the real result set. Raise `limit` (or the GORTEX_SEARCH_TEXT_MAX_LIMIT ceiling) to widen. A `path` filter (when supplied) is applied before this cut, so the floor is a floor of the scoped result set, not of the global one."} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_literal.txt b/internal/mcp/testdata/search_golden/text_literal.txt new file mode 100644 index 000000000..7e5a24d68 --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_literal.txt @@ -0,0 +1 @@ +{"count":2,"matches":[{"path":"server/server.go","line":18,"text":"\treturn \"handled \" + path","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"},{"path":"server/server.go","line":22,"text":"func (s *Server) HandleHealth() string { return \"handled health\" }","symbol_id":"server/server.go::Server.HandleHealth","symbol_name":"HandleHealth"}],"query":"handled"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_missing.txt b/internal/mcp/testdata/search_golden/text_missing.txt new file mode 100644 index 000000000..bfddcc24e --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_missing.txt @@ -0,0 +1 @@ +{"count":0,"matches":[],"query":"no such literal anywhere"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_path.txt b/internal/mcp/testdata/search_golden/text_path.txt new file mode 100644 index 000000000..f7478666b --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_path.txt @@ -0,0 +1 @@ +{"count":4,"matches":[{"path":"server/server.go","line":3,"text":"import \"example.com/app/config\""},{"path":"server/server.go","line":7,"text":"\tcfg *config.Config","symbol_id":"server/server.go::Server","symbol_name":"Server"},{"path":"server/server.go","line":11,"text":"func NewServer(cfg *config.Config) *Server { return \u0026Server{cfg: cfg} }","symbol_id":"server/server.go::NewServer","symbol_name":"NewServer"},{"path":"server/server.go","line":16,"text":"\t\treturn \"invalid config\"","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"}],"query":"config"} \ No newline at end of file diff --git a/internal/mcp/testdata/search_golden/text_regexp.txt b/internal/mcp/testdata/search_golden/text_regexp.txt new file mode 100644 index 000000000..e78b9a75e --- /dev/null +++ b/internal/mcp/testdata/search_golden/text_regexp.txt @@ -0,0 +1 @@ +{"count":4,"matches":[{"path":"main.go","line":11,"text":"\t_ = srv.HandleRequest(\"/\")","symbol_id":"main.go::main","symbol_name":"main"},{"path":"main.go","line":12,"text":"\t_ = srv.HandleHealth()","symbol_id":"main.go::main","symbol_name":"main"},{"path":"server/server.go","line":14,"text":"func (s *Server) HandleRequest(path string) string {","symbol_id":"server/server.go::Server.HandleRequest","symbol_name":"HandleRequest"},{"path":"server/server.go","line":22,"text":"func (s *Server) HandleHealth() string { return \"handled health\" }","symbol_id":"server/server.go::Server.HandleHealth","symbol_name":"HandleHealth"}],"query":"Handle[A-Z][a-z]+\\("} \ No newline at end of file From ae1ea717cb7cf4c6fd5d2ff3b3a8f4eab3fbfc95 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 23:44:48 +0100 Subject: [PATCH 015/138] wip: topology gate test adjustments from review (untested) --- internal/indexer/repo_topology_gates_test.go | 168 +++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 internal/indexer/repo_topology_gates_test.go diff --git a/internal/indexer/repo_topology_gates_test.go b/internal/indexer/repo_topology_gates_test.go new file mode 100644 index 000000000..3c86be641 --- /dev/null +++ b/internal/indexer/repo_topology_gates_test.go @@ -0,0 +1,168 @@ +package indexer + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/reach" +) + +// awaitRepoCoupling waits until the store's repository coupling is read, so a +// patch of lib or app no longer holds the whole graph. +func awaitRepoCoupling(t *testing.T, g graph.Store) { + t.Helper() + require.Eventually(t, func() bool { + finishLib := reach.BeginRepoTopologyMutation(g, "lib") + defer finishLib(false, nil) + admitted := make(chan struct{}) + go func() { + reach.BeginRepoTopologyMutation(g, "app")(false, nil) + close(admitted) + }() + select { + case <-admitted: + return true + case <-time.After(10 * time.Millisecond): + return false + } + }, 5*time.Second, 10*time.Millisecond, "the repository coupling was never read") +} + +// holdRepoPatch starts a patch of one file in prefix and parks it inside its +// topology mutation, after the parse and resolver tails and before the +// derived passes. finish lets the patch complete and returns its error. +func holdRepoPatch(t *testing.T, mi *MultiIndexer, prefix, path, content string) (finish func() error) { + t.Helper() + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + mi.GetIndexer(prefix).incrementalCatchupHook = func(kind string, _ []string) { + if kind == "derived" { + once.Do(func() { + close(entered) + <-release + }) + } + } + writeFile(t, path, content) + done := make(chan error, 1) + go func() { + _, err := mi.IncrementalReindexRepo(prefix, []string{path}) + done <- err + }() + select { + case <-entered: + case err := <-done: + t.Fatalf("patch of %s finished without reaching its derived tail: %v", prefix, err) + case <-time.After(30 * time.Second): + t.Fatalf("patch of %s never reached its derived tail", prefix) + } + var ( + released sync.Once + err error + ) + finish = func() error { + released.Do(func() { + close(release) + err = <-done + }) + return err + } + t.Cleanup(func() { _ = finish() }) + return finish +} + +func TestTopologyGatePerRepoAllowsParallelPatches(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + awaitRepoCoupling(t, g) + finishApp := holdRepoPatch(t, mi, "app", filepath.Join(roots["app"], "main.go"), + "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") + + lib := filepath.Join(roots["lib"], "lib.go") + writeFile(t, lib, "package library\nfunc Hello() {}\nfunc Local() { Hello() }\nfunc More() { Local() }\n") + done := make(chan error, 1) + go func() { + _, err := mi.IncrementalReindexRepo("lib", []string{lib}) + done <- err + }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("a patch of lib waited behind an in-flight patch of app") + } + require.NoError(t, finishApp()) +} + +func TestLookupNotStarvedByOtherRepoWriters(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + awaitRepoCoupling(t, g) + finishApp := holdRepoPatch(t, mi, "app", filepath.Join(roots["app"], "main.go"), + "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") + + started := time.Now() + d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") + elapsed := time.Since(started) + require.True(t, hit) + require.False(t, truncated, "impact on lib degraded to a lower bound after %s behind a patch of app", elapsed) + require.Less(t, elapsed, time.Second) + require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) + require.NoError(t, finishApp()) +} + +func TestForeignRepoEditKeepsReachRecordsHot(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + awaitRepoCoupling(t, g) + reach.BuildIndex(g) + + main := filepath.Join(roots["app"], "main.go") + writeFile(t, main, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") + _, err := mi.IncrementalReindexRepo("app", []string{main}) + require.NoError(t, err) + + _, _, _, hit, _ := reach.LookupCached(g, "app/main.go::Run") + require.False(t, hit, "control: the edited repository's records must be retired") + d1, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") + require.True(t, hit, "an edit in app retired the records of lib") + require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) +} + +// A patch whose resolver pass binds a reference, not a call, from app into +// lib joins the two repositories all the same: the records of lib must not be +// served after it. The resolver hook binds the edge the way a name-based +// resolver pass does, outside the cross-repository candidate pass. +func TestRepoPatchBindingAReferenceRetiresTheOtherRepo(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + awaitRepoCoupling(t, g) + reach.BuildIndex(g) + _, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") + require.True(t, hit, "fixture must start from a published record") + + app := mi.GetIndexer("app") + resolved := false + app.incrementalResolveFilesHook = func(frontier []string) { + mi.runMasterResolveFilesWithEvidence(frontier, false, app.resolver) + g.AddBatch(nil, []*graph.Edge{{ + From: "app/main.go::Extra", To: "lib/lib.go::Hello", Kind: graph.EdgeReferences, + FilePath: "app/main.go", Line: 4, + }}) + resolved = true + } + main := filepath.Join(roots["app"], "main.go") + writeFile(t, main, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") + _, err := mi.IncrementalReindexRepo("app", []string{main}) + require.NoError(t, err) + require.True(t, resolved, "the patch must run its resolver pass") + var bound bool + for _, e := range g.GetInEdges("lib/lib.go::Hello") { + bound = bound || e.From == "app/main.go::Extra" && e.Kind == graph.EdgeReferences + } + require.True(t, bound, "the reference must survive the patch's later passes") + + _, _, _, hit, _ = reach.LookupCached(g, "lib/lib.go::Hello") + require.False(t, hit, "a reference bound from app into lib left the records of lib current") +} From 49ad177dcb5a4ca5d9b987889a3cc4731aa02875 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:28:03 +0100 Subject: [PATCH 016/138] test: cover lost receipts and freshness across text formats --- internal/mcp/freshness_tiers_test.go | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/mcp/freshness_tiers_test.go diff --git a/internal/mcp/freshness_tiers_test.go b/internal/mcp/freshness_tiers_test.go new file mode 100644 index 000000000..5bb402c5c --- /dev/null +++ b/internal/mcp/freshness_tiers_test.go @@ -0,0 +1,48 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestRestartLostReceiptsReturnWithoutWaiting(t *testing.T) { + for _, key := range []string{"receipt", "mutation_id"} { + t.Run(key, func(t *testing.T) { + s := newMutationServer() + req := mcp.CallToolRequest{} + req.Params.Name = "mutation_status" + req.Params.Arguments = map[string]any{key: "mutation-100", "require_fresh": true, + "wait_deadline": time.Now().Add(time.Minute).Format(time.RFC3339Nano)} + res, err := s.handleMutationStatus(context.Background(), req) + require.NoError(t, err) + payload := resultJSON(t, res) + require.Equal(t, "lost", payload["state"]) + require.Equal(t, false, payload["found"]) + require.Equal(t, true, payload["graph_status_terminal"]) + require.NotContains(t, payload, "graph_freshness") + }) + } +} + +func TestFreshnessRiderLabelsEveryOutputMode(t *testing.T) { + rider := map[string]any{"freshness_scope": "relations", "graph_freshness": "pending", + "pending_file_count": 2, "pending_files": []string{"caller.go", "target.go"}} + for _, body := range []string{"clean: checked 2 callers", "callers[1]{name}:\n Caller", "[1,2]"} { + res := decorateResultWithFreshness(mcp.NewToolResultText(body), rider) + require.Equal(t, "relations: pending (2 files)\n"+body, resultText(res)) + } + jsonResult := decorateResultWithFreshness(mcp.NewToolResultText(`{"ok":true}`), rider) + require.Equal(t, "pending", resultJSON(t, jsonResult)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestMutationIDsAreEpochSeeded(t *testing.T) { + // Counters beginning at one can reuse a previous process's receipt after a + // restart, turning an explicitly lost write into an unrelated live write. + const plausibleEpoch = uint64(1_000_000_000_000_000_000) + require.Greater(t, mutationReceiptSequence.Load(), plausibleEpoch) + require.Greater(t, mutationCommitSequence.Load(), plausibleEpoch) +} From 837a0c92b3ba896e91621019d0748cb13d204439 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:28:59 +0100 Subject: [PATCH 017/138] indexer: add live publication tier evidence to mutation tickets --- internal/indexer/mutation_tiers.go | 25 +++++++++++++++++++++++++ internal/indexer/watcher.go | 2 ++ 2 files changed, 27 insertions(+) create mode 100644 internal/indexer/mutation_tiers.go diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go new file mode 100644 index 000000000..ed6880432 --- /dev/null +++ b/internal/indexer/mutation_tiers.go @@ -0,0 +1,25 @@ +package indexer + +import "time" + +// MutationTierState is the publication evidence for one admitted path. An +// absent timestamp is unobserved, rather than evidence of a fresh tier. +type MutationTierState struct { + Generation uint64 `json:"generation"` + AckAt time.Time `json:"ack_at"` + FileGraphAt time.Time `json:"file_graph_at,omitempty"` + RelationsAt time.Time `json:"relations_at,omitempty"` + DerivedAt time.Time `json:"derived_at,omitempty"` + Failed bool `json:"failed,omitempty"` + Partial bool `json:"partial,omitempty"` +} + +// TierState reads a ticket's live stamps; the ticket survives waiter removal. +func (t *MutationTicket) TierState() MutationTierState { + if t == nil { + return MutationTierState{} + } + t.tierMu.RLock() + defer t.tierMu.RUnlock() + return t.tiers +} diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 0b1d05da1..73bf3181c 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -65,6 +65,8 @@ type MutationTicket struct { // events have none. The watcher binds it before waking work; the existing // consumer of Done records completion, never a second channel reader. PublicationRecord *PublicationPhaseRecord `json:"-"` + tierMu sync.RWMutex + tiers MutationTierState } // mutationWaiter owns only outstanding ticket state. Publication metadata is From ead8cd4531a314880d0452edecfa475c6977c86f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:29:19 +0100 Subject: [PATCH 018/138] test: distinguish visible relations from unfinished derived work --- internal/indexer/mutation_tiers_test.go | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 internal/indexer/mutation_tiers_test.go diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go new file mode 100644 index 000000000..ade43ad82 --- /dev/null +++ b/internal/indexer/mutation_tiers_test.go @@ -0,0 +1,50 @@ +package indexer + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestReceiptTierTimestampsAreOrdered(t *testing.T) { + dir, idx, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, "package main\nfunc After() {}\n") + derived, release := make(chan struct{}), make(chan struct{}) + defer close(release) + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "derived" { + close(derived) + <-release + } + } + ticket, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case <-derived: + case <-time.After(2 * time.Second): + t.Fatal("the real patch did not reach its derived pass") + } + tiers := ticket.TierState() + require.False(t, tiers.AckAt.IsZero(), "admission must stamp the live ticket") + require.True(t, tiers.AckAt.Before(tiers.FileGraphAt)) + require.False(t, tiers.RelationsAt.Before(tiers.FileGraphAt)) + require.True(t, tiers.DerivedAt.IsZero(), "relations must publish before blocked derived work") +} + +func TestFailedPatchRetainsOnlyItsTicketTier(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + ticket, err := watcher.EnqueueToolMutation(t.Context(), filepath.Join(dir, "main.go")) + require.NoError(t, err) + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + case <-time.After(2 * time.Second): + t.Fatal("unchanged mutation did not finish") + } + tiers := ticket.TierState() + require.False(t, tiers.DerivedAt.IsZero(), "a successful identity no-op still certifies the admitted bytes") + require.False(t, tiers.Failed) +} From 8ad341d37616ee38da59d998384608e78f07b369 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:33:02 +0100 Subject: [PATCH 019/138] reach: refresh queued writer scope when repository components join --- internal/reach/reach.go | 2 ++ internal/reach/topology_repo_test.go | 37 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/reach/reach.go b/internal/reach/reach.go index f9faec179..179790d67 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -552,6 +552,8 @@ func (c *repoCoupling) union(a, b string) { } c.parent[ra] = rb c.parent[rb] = rb + // Queued writers must recompute their gate set when components grow. + c.epoch++ } // component lists prefix and every repository coupled with it, sorted. diff --git a/internal/reach/topology_repo_test.go b/internal/reach/topology_repo_test.go index 36c6b56ce..28a94fe05 100644 --- a/internal/reach/topology_repo_test.go +++ b/internal/reach/topology_repo_test.go @@ -285,3 +285,40 @@ func TestFailedCouplingReadIsNotRetried(t *testing.T) { t.Fatal("a mutation of a store with no coupling kept the records of another repository") } } + +// A writer that queued before another mutation joined its repository to a +// neighbour must acquire the expanded component when it is admitted. +func TestWaitingRepoTopologyWriterRechecksNewCoupling(t *testing.T) { + store := repoTopologyStore(t) + awaitRepoCoupling(t, store) + finishA := BeginRepoTopologyMutation(store, "a") + finishB := BeginRepoTopologyMutation(store, "b") + queued := make(chan func(bool, []string), 1) + go func() { queued <- BeginRepoTopologyMutation(store, "a") }() + deadline := time.Now().Add(topologyScopeWait) + for { + topology.mu.Lock() + waiting := topology.repos["a"].writersWaiting > 0 + topology.mu.Unlock() + if waiting { + break + } + if time.Now().After(deadline) { + finishA(false, nil) + finishB(false, nil) + t.Fatal("the second writer never queued") + } + time.Sleep(time.Millisecond) + } + store.AddBatch(nil, []*graph.Edge{{From: seedA, To: seedB, Kind: graph.EdgeReferences}}) + finishA(true, []string{"a/a.go"}) + select { + case finish := <-queued: + finish(false, nil) + finishB(false, nil) + t.Fatal("the queued writer entered beside its newly coupled neighbour") + case <-time.After(20 * time.Millisecond): + } + finishB(false, nil) + (<-queued)(false, nil) +} From b38aff734b0b112253c8eb7381112112c893886a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:33:02 +0100 Subject: [PATCH 020/138] indexer: scope incremental topology gates to coupled repositories --- internal/indexer/incremental_derived.go | 3 +- internal/indexer/incremental_watcher_batch.go | 23 ++++++-- internal/indexer/multi.go | 24 ++++++--- internal/indexer/repo_topology_gates_test.go | 54 +++++++++++++++++++ internal/indexer/workspace_resolve.go | 5 +- 5 files changed, 93 insertions(+), 16 deletions(-) diff --git a/internal/indexer/incremental_derived.go b/internal/indexer/incremental_derived.go index 2026f0b17..b8ad4e254 100644 --- a/internal/indexer/incremental_derived.go +++ b/internal/indexer/incremental_derived.go @@ -213,7 +213,8 @@ func (mi *MultiIndexer) RunIncrementalDerivedPasses( } // runIncrementalDerivedPassesTopologyHeld is the non-reentrant implementation -// for mutation pipelines that already own the global topology writer. +// for mutation pipelines that already own the topology-writer gates of every +// repository the mutation can affect. func (mi *MultiIndexer) runIncrementalDerivedPassesTopologyHeld( ctx context.Context, plans map[string]DerivedInvalidationPlan, diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index 1d7e2f425..d1f2b8b69 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -535,13 +535,15 @@ func (gw *GitWatcher) reindexChangedPaths(paths []string) (*IndexResult, error) // when the store cannot certify its eviction shape, the indexer's conservative // successful-file frontier. A whole-graph fallback is reserved for the // exceptional case where neither source yields any safe scope. +// It returns the files where passes may have rebound edges, or nil when a +// name-based or whole-graph pass may have changed edges outside that frontier. func (mi *MultiIndexer) resolveIncrementalRepoMutation( repoPrefix string, result *IndexResult, receipt *graph.MutationReceipt, batch *reparsePendingEnrichmentBatch, -) { - mi.resolveIncrementalRepoMutationMode(repoPrefix, result, receipt, batch, false) +) []string { + return mi.resolveIncrementalRepoMutationMode(repoPrefix, result, receipt, batch, false) } func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( @@ -550,8 +552,9 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( receipt *graph.MutationReceipt, batch *reparsePendingEnrichmentBatch, exactPointSemantic bool, -) { +) []string { files, needed, _ := incrementalResolutionFrontier(result, receipt) + wide := false if result == nil || result.DeletedFileCount > 0 { batch.dropDeferredPriorBindings() } @@ -574,7 +577,9 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( crossRepoFiles = appendUniqueSorted(crossRepoFiles, resolvedFiles...) if receipt != nil && receipt.Complete { names := batch.publicationStamps().beginPatchLap(patchResolve) - mi.runMasterResolveNames(vanishedReceiptNames(mi.graph, receipt)) + vanished := vanishedReceiptNames(mi.graph, receipt) + wide = len(vanished) > 0 + mi.runMasterResolveNames(vanished) names.end() } } else if needed && len(files) > 0 { @@ -583,10 +588,13 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( resolve.end() if receipt != nil && receipt.Complete { names := batch.publicationStamps().beginPatchLap(patchResolve) - mi.runMasterResolveNames(vanishedReceiptNames(mi.graph, receipt)) + vanished := vanishedReceiptNames(mi.graph, receipt) + wide = len(vanished) > 0 + mi.runMasterResolveNames(vanished) names.end() } } else if needed { + wide = true scope := map[string]struct{}{repoPrefix: {}} if repoPrefix == "" { scope = nil @@ -623,8 +631,13 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( if len(crossRepoFiles) > 0 { mi.runCrossRepoResolveFiles(crossRepoFiles) } else { + wide = true mi.runCrossRepoResolve(false) } cross.end() } + if wide { + return nil + } + return crossRepoFiles } diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 37cd1b9dc..6b4dad873 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -2520,9 +2520,10 @@ func (mi *MultiIndexer) incrementalEvictRepoRaw(repoPrefix, path string) (nodesR return 0, 0, fmt.Errorf("repository mutation executor has no live indexer: %s", repoPrefix) } - finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) + finishTopologyMutation := reach.BeginRepoTopologyMutation(mi.graph, repoPrefix) topologyChanged := true - defer func() { finishTopologyMutation(topologyChanged) }() + var topologyFrontier []string + defer func() { finishTopologyMutation(topologyChanged, topologyFrontier) }() eviction := idx.evictFileIncrementalRaw(path) result := eviction.result @@ -2532,7 +2533,7 @@ func (mi *MultiIndexer) incrementalEvictRepoRaw(repoPrefix, path string) (nodesR ) } if result.DeletedFileCount > 0 { - mi.resolveIncrementalRepoMutation(repoPrefix, result, nil, eviction.batch) + topologyFrontier = mi.resolveIncrementalRepoMutation(repoPrefix, result, nil, eviction.batch) } fileMtimes := idx.publishFileMtimes() @@ -2585,19 +2586,22 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ } // The stable per-repository lane is held before this executor runs. Take - // the global reachability topology gate second and keep it through the - // resolver, metadata, and derived tails to avoid lane/gate inversion. + // the repository's reachability topology gates second and keep them through + // the resolver, metadata, and derived tails to avoid lane/gate inversion. gate := mode.publication.beginPatchLap(patchGateWait) - finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) + finishTopologyMutation := reach.BeginRepoTopologyMutation(mi.graph, repoPrefix) gate.end() topologyChanged := true - defer func() { finishTopologyMutation(topologyChanged) }() + var topologyFrontier []string + defer func() { finishTopologyMutation(topologyChanged, topologyFrontier) }() + goModChanged := false for _, path := range paths { if filepath.Base(path) == "go.mod" { mi.goModuleMu.Lock() mi.goModuleChecked = time.Time{} mi.goModuleMu.Unlock() + goModChanged = true break } } @@ -2611,9 +2615,13 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ // changed/definition file frontier; only an incomplete receipt falls back to // conservative scoped-global resolution. Derived invalidations run once // below after both binding layers are current. - mi.resolveIncrementalRepoMutationMode( + frontier := mi.resolveIncrementalRepoMutationMode( repoPrefix, result, receipt, batch, mode.exactPointSemantic, ) + // Module changes and full retracks may rebind edges outside the frontier. + if !goModChanged && !result.FullRetrack { + topologyFrontier = frontier + } mtimes := mode.publication.beginPatchLap(patchMtimes) fileMtimes := idx.publishFileMtimes() diff --git a/internal/indexer/repo_topology_gates_test.go b/internal/indexer/repo_topology_gates_test.go index 3c86be641..9b5899c26 100644 --- a/internal/indexer/repo_topology_gates_test.go +++ b/internal/indexer/repo_topology_gates_test.go @@ -1,7 +1,9 @@ package indexer import ( + "fmt" "path/filepath" + "strings" "sync" "testing" "time" @@ -166,3 +168,55 @@ func TestRepoPatchBindingAReferenceRetiresTheOtherRepo(t *testing.T) { _, _, _, hit, _ = reach.LookupCached(g, "lib/lib.go::Hello") require.False(t, hit, "a reference bound from app into lib left the records of lib current") } + +// Patches of independent repositories run their resolver and derived tails +// concurrently. Exercise their shared state together with impact lookups. +func TestConcurrentRepoPatchTails(t *testing.T) { + for _, coupled := range []bool{false, true} { + t.Run(fmt.Sprintf("coupled=%v", coupled), func(t *testing.T) { + mi, g, roots := repoTopologyFixture(t) + awaitRepoCoupling(t, g) + var wg sync.WaitGroup + patch := func(prefix, file string, body func(i int) string) { + defer wg.Done() + for i := range 12 { + writeFile(t, file, body(i)) + if _, err := mi.IncrementalReindexRepo(prefix, []string{file}); err != nil { + t.Error(err) + return + } + } + } + wg.Add(3) + go patch("lib", filepath.Join(roots["lib"], "lib.go"), func(i int) string { + var b strings.Builder + b.WriteString("package library\nfunc Hello() {}\nfunc Local() { Hello() }\n") + for j := range i % 4 { + fmt.Fprintf(&b, "func Extra%d() { Local() }\n", j) + } + return b.String() + }) + go patch("app", filepath.Join(roots["app"], "main.go"), func(i int) string { + if coupled && i%2 == 1 { + return fmt.Sprintf("package app\nimport library \"example.org/library\"\nfunc Run() {}\nfunc Start() { Run(); library.Hello() }\nfunc V%d() {}\n", i) + } + return fmt.Sprintf("package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc V%d() { Start() }\n", i) + }) + go func() { + defer wg.Done() + for range 60 { + reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") + reach.LookupContext(t.Context(), g, "app/main.go::Run") + } + }() + wg.Wait() + d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Local") + if !hit || truncated { + t.Fatalf("final lookup hit=%v truncated=%v", hit, truncated) + } + if got := reachIDs(d1); len(got) != 3 { + t.Fatalf("callers of Local after the last patch = %v, want Extra0..Extra2", got) + } + }) + } +} diff --git a/internal/indexer/workspace_resolve.go b/internal/indexer/workspace_resolve.go index 1a88aac47..e90b46440 100644 --- a/internal/indexer/workspace_resolve.go +++ b/internal/indexer/workspace_resolve.go @@ -542,8 +542,9 @@ func (mi *MultiIndexer) runCrossRepoResolveContext(ctx context.Context, reconcil // runCrossRepoResolveFiles binds both outgoing and incoming unresolved edges // for one coordinated mutation frontier. Its caller owns the repository lane -// and global topology-writer gate, so the graph cannot expose a half-resolved -// batch between same-repo, semantic, and cross-repository phases. +// and the topology-writer gates of every repository the mutation can affect, so +// the graph cannot expose a half-resolved batch between same-repo, semantic, +// and cross-repository phases. func (mi *MultiIndexer) runCrossRepoResolveFiles(filePaths []string) { mi.runCrossRepoResolveMutationFiles(filePaths, filePaths) } From 766a934f329a52fe06467d01f412b7ee63470bec Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:37:47 +0100 Subject: [PATCH 021/138] indexer: publish relation tiers before derived mutation work --- internal/indexer/incremental_watcher_batch.go | 31 +++- internal/indexer/indexer.go | 1 + internal/indexer/multi.go | 3 + internal/indexer/multi_watcher.go | 6 - internal/indexer/mutation_tiers.go | 163 +++++++++++++++++- internal/indexer/mutation_tiers_test.go | 2 +- internal/indexer/watcher.go | 66 ++++--- 7 files changed, 231 insertions(+), 41 deletions(-) diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index 1d7e2f425..f4ecf5287 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -406,6 +406,9 @@ func (idx *Indexer) incrementalWatcherPaths(root string, paths []string, mode in idx.ResolveAll() resolve.end() } + if mode.onRelations != nil { + mode.onRelations() + } if mode.forceExplicitFiles || !idx.deferGlobalPasses.Load() { if mode.exactPointSemantic { idx.runIncrementalPointSemantic(result.DerivedInvalidation.Files) @@ -480,7 +483,15 @@ func vanishedReceiptNames(g graph.Store, receipt *graph.MutationReceipt) []strin // reindexExplicitMutationPaths preserves forced point-read semantics for the // complete admitted set, with one lane-owned resolver/derived tail. -func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, DerivedFanoutCompleteness, error) { +func (w *Watcher) reindexExplicitMutationPaths(paths []string, admitted ...map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { + frontier := w.mutationTierFrontier(paths) + if len(admitted) > 0 { + frontier = admitted[0] + } + return w.reindexMutationPaths(paths, true, frontier) +} + +func (w *Watcher) reindexMutationPaths(paths []string, force bool, frontier map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { var result *IndexResult var fanout DerivedFanoutCompleteness ctx, cancel := w.mutationLaneContext() @@ -492,11 +503,10 @@ func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, De } observation := beginDerivedFanoutObservation(idx) defer observation.close() - mode := incrementalPathMode{detectDeletions: true, forceExplicitFiles: true} + mode := incrementalPathMode{detectDeletions: true, forceExplicitFiles: force, + onRelations: func() { w.markRelationsFrontier(frontier) }} var err error - if w.explicitReindexRaw != nil { - result, err = w.explicitReindexRaw(paths) - } else if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { + if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { result, err = owner.incrementalReindexRepoRawMode(idx.repoPrefix, paths, mode) } else { result, err = idx.incrementalWatcherPaths(idx.rootPath, paths, mode) @@ -510,14 +520,19 @@ func (w *Watcher) reindexExplicitMutationPaths(paths []string) (*IndexResult, De return result, fanout, err } -func (w *Watcher) reindexStormPaths(paths []string) (*IndexResult, error) { - if w.batchReindex != nil { +func (w *Watcher) reindexStormPaths(paths []string, admitted ...map[string]uint64) (*IndexResult, error) { + if w.batchReindex != nil && (w.indexer == nil || w.indexer.repositoryMutationOwner == nil) { return w.batchReindex(paths) } if w.indexer == nil || w.indexer.rootPath == "" { return nil, fmt.Errorf("watcher: index root is not initialized") } - return w.indexer.IncrementalReindexPaths(w.indexer.rootPath, paths) + frontier := w.mutationTierFrontier(paths) + if len(admitted) > 0 { + frontier = admitted[0] + } + result, _, err := w.reindexMutationPaths(paths, false, frontier) + return result, err } func (gw *GitWatcher) reindexChangedPaths(paths []string) (*IndexResult, error) { diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 035ab698c..799196b74 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -7017,6 +7017,7 @@ type incrementalPathMode struct { // mutable Indexer tracing state is installed by this per-call observation. publication *PublicationStamps readRepair *ReadRepairObservation + onRelations func() // resolver and file publication complete, before derived work } // indexedFilesAbsentFromDisk returns the repo-relative keys of files the graph diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 37cd1b9dc..41f2170c9 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -2639,6 +2639,9 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ mi.mu.Unlock() mtimes.end() + if mode.onRelations != nil { + mode.onRelations() + } idx.observeIncrementalCatchup("derived", result.DerivedInvalidation.Files) derived := mode.publication.beginDerivedPatchLap() mi.runIncrementalDerivedPassesWithPriorTopologyHeld(context.Background(), map[string]DerivedInvalidationPlan{ diff --git a/internal/indexer/multi_watcher.go b/internal/indexer/multi_watcher.go index 0a347d395..15c531347 100644 --- a/internal/indexer/multi_watcher.go +++ b/internal/indexer/multi_watcher.go @@ -248,12 +248,6 @@ func (mw *MultiWatcher) createWatcher(prefix string, cfg config.WatchConfig) err w.discoverReindex = func(paths []string) (*IndexResult, error) { return mw.multi.incrementalDiscoverRepo(prefix, paths) } - w.explicitReindexRaw = func(paths []string) (*IndexResult, error) { - return mw.multi.incrementalReindexRepoRawMode(prefix, paths, incrementalPathMode{detectDeletions: true, forceExplicitFiles: true}) - } - w.pointReindexRaw = func(filePath string, stamps *PublicationStamps) (*IndexResult, error) { - return mw.multi.incrementalPointRepoRawObserved(prefix, filePath, stamps) - } mw.watchers[prefix] = w return nil diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index ed6880432..c3d324e72 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -1,6 +1,9 @@ package indexer -import "time" +import ( + "path/filepath" + "time" +) // MutationTierState is the publication evidence for one admitted path. An // absent timestamp is unobserved, rather than evidence of a fresh tier. @@ -12,6 +15,164 @@ type MutationTierState struct { DerivedAt time.Time `json:"derived_at,omitempty"` Failed bool `json:"failed,omitempty"` Partial bool `json:"partial,omitempty"` + failedAt time.Time +} + +func newMutationTierTicket(path string, generation uint64, done <-chan MutationResult) *MutationTicket { + return &MutationTicket{Path: path, Generation: generation, Done: done, + tiers: MutationTierState{Generation: generation, AckAt: time.Now()}} +} + +func (w *Watcher) markRelations(path string, generation uint64) { + w.mu.Lock() + defer w.mu.Unlock() + for ordinal, waiter := range w.mutationWaiters[path] { + if ordinal <= generation && waiter.ticket != nil { + waiter.ticket.tierMu.Lock() + stampRelations(&waiter.ticket.tiers, time.Now()) + waiter.ticket.tierMu.Unlock() + } + } +} + +func stampRelations(state *MutationTierState, at time.Time) { + if state.FileGraphAt.IsZero() { + state.FileGraphAt = at + } + if state.RelationsAt.IsZero() { + state.RelationsAt = at + } +} + +func (w *Watcher) mutationTierFrontier(paths []string) map[string]uint64 { + w.mu.Lock() + defer w.mu.Unlock() + frontier := make(map[string]uint64, len(paths)) + for _, path := range paths { + frontier[path] = w.pendingGeneration[path] + } + return frontier +} + +func (w *Watcher) markRelationsFrontier(frontier map[string]uint64) { + for path, generation := range frontier { + w.markRelations(path, generation) + } +} + +// finishMutationTierLocked runs before removing a waiter or delivering Done. +// Successful paths retain no watcher history; the caller's ticket owns stamps. +func (w *Watcher) finishMutationTierLocked(path string, ticket *MutationTicket, err error, fanout DerivedFanoutCompleteness) { + if ticket == nil { + return + } + ticket.tierMu.Lock() + defer ticket.tierMu.Unlock() + state := &ticket.tiers + if err != nil { + state.Failed = true + state.failedAt = time.Now() + if w.failedPaths == nil { + w.failedPaths = make(map[string]MutationTierState) + } + if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + w.failedPaths[path] = *state + } + return + } + at := time.Now() + stampRelations(state, at) // identity no-ops and test executors have no tail + state.DerivedAt = at + state.Partial = fanout.Observed && !fanout.Complete + if state.Partial { + state.failedAt = at + if w.failedPaths == nil { + w.failedPaths = make(map[string]MutationTierState) + } + if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + w.failedPaths[path] = *state + } + } else if prior := w.failedPaths[path]; prior.Generation <= state.Generation { + delete(w.failedPaths, path) + } +} + +// TierPaths returns only outstanding paths and retained per-path failures. +// It shares the waiter's existing path index; idle reads allocate no map. +func (w *Watcher) TierPaths() map[string]MutationTierState { + w.mu.Lock() + defer w.mu.Unlock() + var states map[string]MutationTierState + put := func(path string, state MutationTierState) { + if states == nil { + states = make(map[string]MutationTierState) + } + if states[path].Generation <= state.Generation { + states[path] = state + } + } + for path, state := range w.failedPaths { + if time.Since(state.failedAt) >= 10*time.Minute { + delete(w.failedPaths, path) + continue + } + put(path, state) + } + for path, byGeneration := range w.mutationWaiters { + for _, waiter := range byGeneration { + if waiter.ticket != nil { + put(path, waiter.ticket.TierState()) + } + } + } + return states +} + +func (w *Watcher) TierState(path string) MutationTierState { + w.mu.Lock() + defer w.mu.Unlock() + path = filepath.Clean(path) + state := w.failedPaths[path] + if !state.failedAt.IsZero() && time.Since(state.failedAt) >= 10*time.Minute { + delete(w.failedPaths, path) + state = MutationTierState{} + } + for generation, waiter := range w.mutationWaiters[path] { + if generation >= state.Generation && waiter.ticket != nil { + state = waiter.ticket.TierState() + } + } + return state +} + +func (mw *MultiWatcher) TierPaths() map[string]MutationTierState { + mw.mu.Lock() + watchers := make([]*Watcher, 0, len(mw.watchers)) + for _, watcher := range mw.watchers { + watchers = append(watchers, watcher) + } + mw.mu.Unlock() + var states map[string]MutationTierState + for _, watcher := range watchers { + for path, state := range watcher.TierPaths() { + if states == nil { + states = make(map[string]MutationTierState) + } + states[path] = state + } + } + return states +} + +func (mw *MultiWatcher) TierState(path string) MutationTierState { + mw.mu.Lock() + _, prefix := mw.multi.IndexerForFile(path) + watcher := mw.watchers[prefix] + mw.mu.Unlock() + if watcher != nil { + return watcher.TierState(path) + } + return MutationTierState{} } // TierState reads a ticket's live stamps; the ticket survives waiter removal. diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index ade43ad82..f5b9ec642 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -34,7 +34,7 @@ func TestReceiptTierTimestampsAreOrdered(t *testing.T) { require.True(t, tiers.DerivedAt.IsZero(), "relations must publish before blocked derived work") } -func TestFailedPatchRetainsOnlyItsTicketTier(t *testing.T) { +func TestIdentityPatchPublishesTicketTiers(t *testing.T) { dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") ticket, err := watcher.EnqueueToolMutation(t.Context(), filepath.Join(dir, "main.go")) require.NoError(t, err) diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 73bf3181c..2380a8653 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -73,6 +73,7 @@ type MutationTicket struct { // released with the same completion/stop paths as its existing result channel; // it is not a separate tracing registry or completed-history collection. type mutationWaiter struct { + ticket *MutationTicket tool bool done chan MutationResult publication *PublicationPhaseRecord @@ -191,7 +192,8 @@ type Watcher struct { pending map[string]*time.Timer pendingGeneration map[string]uint64 mutationWaiters map[string]map[uint64]mutationWaiter - heldNative map[string]ChangeKind // native echoes awaiting outstanding tool receipts; guarded by mu + failedPaths map[string]MutationTierState // retained failures only; guarded by mu + heldNative map[string]ChangeKind // native echoes awaiting outstanding tool receipts; guarded by mu mutationEchoesAttached uint64 mutationSuperseded uint64 mutationWaitersLater uint64 @@ -253,21 +255,20 @@ type Watcher struct { // Storm-mode state. Guarded by stormMu so the hot per-file // debounce path (mu) doesn't contend with rate-tracking. - stormMu sync.Mutex - eventTimes []time.Time // sliding window of recent event timestamps - stormBatch map[string]ChangeKind // dirty set during an event storm - stormGenerations map[string]uint64 // newest debounced generation adopted per path - stormTimer *time.Timer // fires after the quiet period - stormActive bool // true while waiting to drain - stormExplicit bool // admitted complete explicit frontier requires forced reads - stormStopped bool // Stop has closed storm admission - stormRetryAttempt int // retry ordinal for the published storm timer - stormWork sync.WaitGroup // scheduled/running timer callbacks - stormDrained func(int) // test hook: batch drained; batch size arg - stormBeforeLock func() // test hook: immediately before repository-lane admission - explicitReindexRaw watcherBatchReindex // complete forced frontier; caller already holds repository lane - batchReindex watcherBatchReindex // one bounded batch; MultiWatcher installs shared catch-up - discoverReindex watcherBatchReindex // additive directory discovery with the same complete tail + stormMu sync.Mutex + eventTimes []time.Time // sliding window of recent event timestamps + stormBatch map[string]ChangeKind // dirty set during an event storm + stormGenerations map[string]uint64 // newest debounced generation adopted per path + stormTimer *time.Timer // fires after the quiet period + stormActive bool // true while waiting to drain + stormExplicit bool // admitted complete explicit frontier requires forced reads + stormStopped bool // Stop has closed storm admission + stormRetryAttempt int // retry ordinal for the published storm timer + stormWork sync.WaitGroup // scheduled/running timer callbacks + stormDrained func(int) // test hook: batch drained; batch size arg + stormBeforeLock func() // test hook: immediately before repository-lane admission + batchReindex watcherBatchReindex // one bounded batch; MultiWatcher installs shared catch-up + discoverReindex watcherBatchReindex // additive directory discovery with the same complete tail // pointReindexRaw runs the complete exact-path pipeline after the watcher // already owns the repository lane. MultiWatcher installs its raw tail here. pointReindexRaw func(string, *PublicationStamps) (*IndexResult, error) @@ -1949,11 +1950,12 @@ func (w *Watcher) EnqueueFileMutations(ctx context.Context, files []string) (map w.mutationWaiters[path] = make(map[uint64]mutationWaiter) } done := make(chan MutationResult, 1) - w.mutationWaiters[path][generation] = mutationWaiter{done: done, tool: true} + ticket := newMutationTierTicket(path, generation, done) + w.mutationWaiters[path][generation] = mutationWaiter{done: done, tool: true, ticket: ticket} w.pendingGeneration[path] = generation w.stormGenerations[path] = generation w.stormBatch[path] = ChangeModified - tickets[path] = &MutationTicket{Path: path, Generation: generation, Done: done} + tickets[path] = ticket } w.stormActive = true w.stormExplicit = true @@ -2043,7 +2045,7 @@ func (w *Watcher) scheduleMutationEnteredEvent(path string, kind ChangeKind, rec } done := make(chan MutationResult, 1) w.pendingGeneration[path] = generation - ticket := &MutationTicket{Path: path, Generation: generation, Done: done} + ticket := newMutationTierTicket(path, generation, done) if record != nil && record.bindCanonicalTicket(generation, time.Now()) { ticket.PublicationRecord = record if w.observedMutationGeneration == nil { @@ -2051,7 +2053,7 @@ func (w *Watcher) scheduleMutationEnteredEvent(path string, kind ChangeKind, rec } w.observedMutationGeneration[path] = generation } - w.mutationWaiters[path][generation] = mutationWaiter{done: done, publication: ticket.PublicationRecord, schedulingEntered: entered, backgroundEligible: backgroundEligible, tool: tool} + w.mutationWaiters[path][generation] = mutationWaiter{done: done, ticket: ticket, publication: ticket.PublicationRecord, schedulingEntered: entered, backgroundEligible: backgroundEligible, tool: tool} delay := time.Duration(w.config.DebounceMs) * time.Millisecond if tool { delay = 0 @@ -2369,6 +2371,7 @@ func (w *Watcher) completeMutationWaitersObserved( var accepted *PublicationPhaseRecord for generation, waiter := range w.mutationWaiters[path] { if generation <= appliedGeneration { + w.finishMutationTierLocked(path, waiter.ticket, err, fanout) if err == nil && generation < appliedGeneration { w.mutationWaitersLater++ } @@ -2417,6 +2420,11 @@ func (w *Watcher) completeMutationWaitersObserved( func (w *Watcher) failMutationWaiters(err error) { w.mu.Lock() waiters := w.mutationWaiters + for path, byGeneration := range waiters { + for _, waiter := range byGeneration { + w.finishMutationTierLocked(path, waiter.ticket, err, DerivedFanoutCompleteness{}) + } + } w.mutationWaiters = nil w.heldNative = nil w.observedMutationGeneration = nil @@ -2626,9 +2634,9 @@ func (w *Watcher) drainStorm() { var err error var fanout DerivedFanoutCompleteness if explicit { - result, fanout, err = w.reindexExplicitMutationPaths(paths) + result, fanout, err = w.reindexExplicitMutationPaths(paths, generations) } else { - result, err = w.reindexStormPaths(paths) + result, err = w.reindexStormPaths(paths, generations) } reindexed, deleted, failed := 0, 0, 0 @@ -2753,6 +2761,7 @@ func (w *Watcher) completeStormMutationWaiters( if requestedGeneration > appliedGeneration { continue } + w.finishMutationTierLocked(path, waiter.ticket, pathErr, pathFanout) if pathErr == nil && requestedGeneration < appliedGeneration { w.mutationWaitersLater++ } @@ -2948,14 +2957,21 @@ func pointMutationClassification( // reindexPointPathRaw runs after the caller has acquired the repository lane. // MultiWatcher supplies the cross-repository resolver and derived tail; a // standalone Watcher falls back to the equivalent single-repository tail. -func (w *Watcher) reindexPointPathRaw(idx *Indexer, path string, stamps *PublicationStamps) (*IndexResult, error) { +func (w *Watcher) reindexPointPathRaw(idx *Indexer, path string, generation uint64, stamps *PublicationStamps) (*IndexResult, error) { if w.pointReindexRaw != nil { return w.pointReindexRaw(path, stamps) } if idx == nil || idx.rootPath == "" { return nil, errWatcherIndexerMissing } - return idx.incrementalPointWatcherPathObserved(idx.rootPath, path, stamps) + mode := incrementalPathMode{ + detectDeletions: true, forceExplicitFiles: true, publication: stamps, + onRelations: func() { w.markRelations(path, generation) }, + } + if owner := idx.repositoryMutationOwner; owner != nil && idx.repoPrefix != "" { + return owner.incrementalReindexRepoRawMode(idx.repoPrefix, []string{path}, mode) + } + return idx.incrementalWatcherPaths(idx.rootPath, []string{path}, mode) } // patchGraphWithReceiptStateRawModern replaces the historical hand-built point @@ -3040,7 +3056,7 @@ func (w *Watcher) patchGraphWithReceiptStateRawModern( observation := beginDerivedFanoutObservation(idx) defer observation.close() stamps.switchAttemptControlAt(patchExecutorControl, markPointPublication(ctx, PublicationPatchExecutionStarted)) - result, err := w.reindexPointPathRaw(idx, path, PublicationStampsFrom(ctx)) + result, err := w.reindexPointPathRaw(idx, path, generation, PublicationStampsFrom(ctx)) stamps.switchAttemptControlAt(patchAttemptFinalize, markPointPublication(ctx, PublicationPatchExecutionFinished)) observed := observation.close() if err != nil { From 3d8366e026f03c40730ac7f97b21f75cb8847ecd Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:42:06 +0100 Subject: [PATCH 022/138] mcp: report lost receipts and retain live mutation tier stamps --- internal/mcp/edit_serialization.go | 5 ++-- internal/mcp/mutation_commit.go | 2 +- internal/mcp/mutation_commit_test.go | 5 ++-- .../mcp/mutation_status_freshness_test.go | 11 ++++----- internal/mcp/mutation_tiers.go | 19 +++++++++++++++ internal/mcp/tools_mutation_status.go | 23 +++++++++++++------ 6 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 internal/mcp/mutation_tiers.go diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index d4e34af21..d8f8396d2 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -7,7 +7,6 @@ import ( "path/filepath" "sort" "sync" - "sync/atomic" "time" "github.com/zzet/gortex/internal/daemon" @@ -21,7 +20,7 @@ const ( mutationReceiptRetention = 10 * time.Minute ) -var mutationReceiptSequence atomic.Uint64 +var mutationReceiptSequence = epochMutationSequence() // mutationPathLocks serializes read-modify-write tool calls per on-disk path. // MCP requests can run concurrently, and atomic rename only makes each write @@ -71,6 +70,7 @@ type mutationReceipt struct { mu sync.RWMutex result indexer.MutationResult completed bool + ticket *indexer.MutationTicket checkoutScoped bool checkoutID string checkoutIncarnation string @@ -244,6 +244,7 @@ func (s *Server) trackCheckoutRecoveryTicket(ticket *indexer.CheckoutRefreshTick func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, checkoutID, incarnation string, recoveryCandidates map[string]struct{}) *mutationReceipt { receipt := &mutationReceipt{ + ticket: ticket, id: fmt.Sprintf("mutation-%d", mutationReceiptSequence.Add(1)), repo: repo, path: ticket.Path, diff --git a/internal/mcp/mutation_commit.go b/internal/mcp/mutation_commit.go index ebea6fb81..10912b5bb 100644 --- a/internal/mcp/mutation_commit.go +++ b/internal/mcp/mutation_commit.go @@ -86,7 +86,7 @@ const mutationIDParamDescription = "Optional idempotency key. Retrying with the "replays the first result instead of writing twice — the safe way to retry an abandoned call. " + "A different edit under the same key is refused." -var mutationCommitSequence atomic.Uint64 +var mutationCommitSequence = epochMutationSequence() // mutationDiskCommitSequence orders disk commits. It is stamped after the // rename and before the commit's refresh ticket is admitted, so a fresh diff --git a/internal/mcp/mutation_commit_test.go b/internal/mcp/mutation_commit_test.go index 4d4786e07..8e64760ac 100644 --- a/internal/mcp/mutation_commit_test.go +++ b/internal/mcp/mutation_commit_test.go @@ -312,15 +312,14 @@ func TestMutationStatusResolvesReceiptPathAndListing(t *testing.T) { require.Equal(t, "unrecorded", unknown["disk_status"]) } -func TestMutationStatusUnknownReceiptIsAnError(t *testing.T) { +func TestMutationStatusUnknownReceiptIsLost(t *testing.T) { s := newMutationServer() req := mcp.CallToolRequest{} req.Params.Name = "mutation_status" req.Params.Arguments = map[string]any{"receipt": "commit-does-not-exist"} res, err := s.handleMutationStatus(context.Background(), req) require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, resultText(res), "no mutation receipt") + require.Equal(t, "lost", resultJSON(t, res)["state"]) } // --- Criterion: retrying after a timeout is idempotent --------------------- diff --git a/internal/mcp/mutation_status_freshness_test.go b/internal/mcp/mutation_status_freshness_test.go index d98f6ac9f..790f76a0c 100644 --- a/internal/mcp/mutation_status_freshness_test.go +++ b/internal/mcp/mutation_status_freshness_test.go @@ -40,19 +40,16 @@ func TestMutationStatusFallsThroughFreshnessReceiptID(t *testing.T) { } }) - t.Run("missing receipt keeps error and adds field hint", func(t *testing.T) { + t.Run("missing receipt reports lost", func(t *testing.T) { s := newMutationServer() id := newID() res, err := s.handleMutationStatus(context.Background(), request(id)) require.NoError(t, err) - require.True(t, res.IsError) - require.Contains(t, resultText(res), "no mutation recorded for mutation_id "+id) - require.Contains(t, resultText(res), "change.receipt options.receipt="+id) + require.Equal(t, "lost", resultJSON(t, res)["state"]) for _, callerKey := range []string{"mutation-", "mutation-abc", "mutation-1suffix", "mutation-12", "prefix-mutation-1"} { res, err = s.handleMutationStatus(context.Background(), request(callerKey)) require.NoError(t, err) - require.True(t, res.IsError) - require.Equal(t, "no mutation recorded for mutation_id "+callerKey, resultText(res)) + require.Equal(t, "lost", resultJSON(t, res)["state"]) } }) @@ -87,7 +84,7 @@ func TestMutationStatusFallsThroughFreshnessReceiptID(t *testing.T) { if scope.checkoutID == "selected" && scope.incarnation == "current" { require.Equal(t, id, resultJSON(t, res)["receipt"]) } else { - require.True(t, res.IsError) + require.Equal(t, "lost", resultJSON(t, res)["state"]) require.NotContains(t, resultText(res), receipt.path) } } diff --git a/internal/mcp/mutation_tiers.go b/internal/mcp/mutation_tiers.go new file mode 100644 index 000000000..5605ff1af --- /dev/null +++ b/internal/mcp/mutation_tiers.go @@ -0,0 +1,19 @@ +package mcp + +import ( + "sync/atomic" + "time" +) + +func epochMutationSequence() *atomic.Uint64 { + sequence := new(atomic.Uint64) + sequence.Store(uint64(time.Now().UnixNano())) + return sequence +} + +func lostMutationPayload(key, id string) map[string]any { + return map[string]any{ + key: id, "found": false, "state": "lost", "graph_status_terminal": true, + "note": "this process has no retained receipt; read the current file or request a scoped refresh; a lost receipt cannot certify freshness", + } +} diff --git a/internal/mcp/tools_mutation_status.go b/internal/mcp/tools_mutation_status.go index c25cfdece..cb5bed005 100644 --- a/internal/mcp/tools_mutation_status.go +++ b/internal/mcp/tools_mutation_status.go @@ -42,10 +42,11 @@ func (s *Server) handleMutationStatus(ctx context.Context, req mcp.CallToolReque return s.respondJSONOrTOON(ctx, req, payload) } } - if !ok || !mutationCommitInScope(ctx, record) { - return mcp.NewToolResultError( - "no mutation receipt " + receipt + " — receipts are kept for " + mutationCommitRetention.String() + - "; query by path instead, or read the file to see its current state"), nil + if ok && !mutationCommitInScope(ctx, record) { + return mcp.NewToolResultError("no mutation receipt " + receipt), nil + } + if !ok { + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("receipt", receipt)) } return s.respondJSONOrTOON(ctx, req, s.mutationStatusPayload(record)) @@ -56,12 +57,14 @@ func (s *Server) handleMutationStatus(ctx context.Context, req mcp.CallToolReque payload["note"] = "this is a graph freshness receipt id, not a caller mutation_id" return s.respondJSONOrTOON(ctx, req, payload) } - return mcp.NewToolResultError("no mutation recorded for mutation_id " + mutationID + - " — use change.receipt options.receipt=" + mutationID + " to query a graph freshness receipt"), nil + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("mutation_id", mutationID)) } - if !ok || !mutationCommitInScope(ctx, record) { + if ok && !mutationCommitInScope(ctx, record) { return mcp.NewToolResultError("no mutation recorded for mutation_id " + mutationID), nil } + if !ok { + return s.respondJSONOrTOON(ctx, req, lostMutationPayload("mutation_id", mutationID)) + } return s.respondJSONOrTOON(ctx, req, s.mutationStatusPayload(record)) case rawPath != "": @@ -163,6 +166,7 @@ func (s *Server) graphRefreshReceiptPayload(ctx context.Context, id string) (map } attachMutationPhaseTimings(payload, receipt.id, time.Time{}, indexer.PublicationSourceFreshRequest, retained) attachAcknowledgementPayload(payload, acknowledgement, acknowledgementState) + attachReceiptTiers(payload, receipt) return payload, true } @@ -248,6 +252,11 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] acknowledgement = &snapshot } attachAcknowledgementPayload(payload, acknowledgement, "unavailable_recording_or_evicted") + if value, found := s.mutationReceipts.Load(snap.ReindexReceipt); found { + if receipt, ok := value.(*mutationReceipt); ok { + attachReceiptTiers(payload, receipt) + } + } return payload } From 681369f5e33d708afa474b2783675e6dec43c29f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:45:09 +0100 Subject: [PATCH 023/138] mcp: share watcher-backed freshness labels and primary waits --- internal/mcp/checkout_binding.go | 19 ++- internal/mcp/facade_tools.go | 14 +- internal/mcp/freshness_rider.go | 228 ++++++++++++++++++++++++++- internal/mcp/freshness_tiers_test.go | 92 +++++++++++ internal/mcp/overlay.go | 11 +- internal/mcp/relation_freshness.go | 104 +++--------- internal/mcp/view_request.go | 5 +- 7 files changed, 362 insertions(+), 111 deletions(-) diff --git a/internal/mcp/checkout_binding.go b/internal/mcp/checkout_binding.go index baa0fcfd9..9f1321615 100644 --- a/internal/mcp/checkout_binding.go +++ b/internal/mcp/checkout_binding.go @@ -19,14 +19,15 @@ import ( // rather than of any one tool — the middleware reads them for every tool, no // handler ever does — so they are named once here. const ( - requireExactArgName = "require_exact" - requireFreshArgName = "require_fresh" - waitDeadlineArgName = "wait_deadline" + requireExactArgName = "require_exact" + requireFreshArgName = "require_fresh" + waitDeadlineArgName = "wait_deadline" + freshnessScopeArgName = "freshness_scope" ) // requestFreshnessArgNames is the accepted set, in the order the guide states // them. -var requestFreshnessArgNames = []string{requireExactArgName, requireFreshArgName, waitDeadlineArgName} +var requestFreshnessArgNames = []string{requireExactArgName, requireFreshArgName, waitDeadlineArgName, freshnessScopeArgName} // requestFreshnessArgKeys is THE allowlist that makes the three knobs callable // on every tool. It is the single place the set is enumerated for dispatch: @@ -104,6 +105,7 @@ type requestFreshness struct { // requireFresh asks for a bounded wait until the selected checkout route // reflects the working copy as it is now. requireFresh bool + scope string // deadline bounds that wait. It is absolute (RFC3339) so a retry carries // the same ceiling rather than restarting the clock. deadline time.Time @@ -173,6 +175,15 @@ func takeRequestFreshness(req *mcp.CallToolRequest) requestFreshness { requireExact: requestRequiresExactCheckoutView(req), requireFresh: requestBoolArg(req, requireFreshArgName), } + if raw, present := requestRawArg(req, freshnessScopeArgName); present { + value, ok := raw.(string) + if !ok || value != "file" && value != "relations" && value != "derived" { + freshness.err = graphview.NewViewError(graphview.CodeInvalidViewSelector, + "freshness_scope must be file, relations, or derived") + return freshness + } + freshness.scope = value + } raw, present := requestRawArg(req, waitDeadlineArgName) if !present || raw == nil { return freshness diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 474cefcf4..00c7c9bb9 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -1177,12 +1177,12 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest // handler actually retrieved, not the riders bolted on afterwards. raw := result afterFreshSymbolAcceptance(ctx, func() { s.recordRetrievalSavings(ctx, spec.Legacy, raw) }) - result = s.decorateFacadeFreshness(spec.Legacy, forwarded, result) + result = s.decorateFacadeFreshness(ctx, spec.Legacy, forwarded, result) } result = decorateFacadeResultIdentity(result, spec) if state, _ := ctx.Value(freshSymbolAttemptKey{}).(*freshSymbolAttempt); state != nil { state.decorate = func(raw *mcpgo.CallToolResult) *mcpgo.CallToolResult { - return decorateFacadeResultIdentity(s.decorateFacadeFreshness(spec.Legacy, forwarded, raw), spec) + return decorateFacadeResultIdentity(s.decorateFacadeFreshness(ctx, spec.Legacy, forwarded, raw), spec) } } return result, err @@ -1483,14 +1483,8 @@ func blockedAnalyzeKindResult(kind string) *mcpgo.CallToolResult { // The outer facade middleware only sees compact names/targets (read, // relations, target.file, ...), so applying the policy there would miss the // legacy path/id fields the rider is deliberately keyed to. -func (s *Server) decorateFacadeFreshness(legacy string, req mcpgo.CallToolRequest, result *mcpgo.CallToolResult) *mcpgo.CallToolResult { - if rider := s.freshnessRiderFor(legacy, req); rider != nil { - return decorateResultWithFreshness(result, rider) - } - if isFreshnessListTool(legacy) { - return s.decorateListResultWithFreshness(result) - } - return result +func (s *Server) decorateFacadeFreshness(ctx context.Context, legacy string, req mcpgo.CallToolRequest, result *mcpgo.CallToolResult) *mcpgo.CallToolResult { + return s.decorateToolFreshness(ctx, legacy, req, result) } func facadeLegacyManagesOwnOverlay(name string) bool { diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 3c8dfd58b..8ab79af10 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -1,12 +1,16 @@ package mcp import ( + "context" "encoding/json" + "fmt" "os" "path/filepath" "slices" "strings" + "time" + wire "github.com/gortexhq/gcx-go" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" @@ -498,7 +502,7 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) } var asObj map[string]any if json.Unmarshal([]byte(text), &asObj) != nil { - return res + return renderFreshnessLine(res, rider) } if _, exists := asObj["freshness"]; exists { return res @@ -511,6 +515,228 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) return rebuildTextResult(res, string(body)) } +func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.CallToolResult { + state, _ := fields["graph_freshness"].(string) + scope, _ := fields["freshness_scope"].(string) + if state == "" { + if fields["stale"] != true && fields["missing"] != true && fields["index_frozen"] != true { + return res + } + state, scope = "pending", "file" + } + if state == "fresh" || state == "" { + return res + } + if scope == "" { + scope = "relations" + } + count := fields["pending_file_count"] + if count == nil { + count = 1 + } + line := fmt.Sprintf("%s: %s (%v files)", scope, state, count) + body, ok := singleTextContent(res) + if !ok || strings.HasPrefix(body, line+"\n") { + return res + } + if strings.HasPrefix(body, wire.Tag) { + body, _ = injectGCXHeaderMeta(body, fields) + head, rows, _ := strings.Cut(body, wire.RowSep) + if strings.HasPrefix(rows, wire.CommentPrefix+" "+line+wire.RowSep) { + return rebuildTextResult(res, body) + } + body = head + wire.RowSep + wire.CommentPrefix + " " + line + wire.RowSep + rows + } else { + body = line + "\n" + body + } + return rebuildTextResult(res, body) +} + +// freshnessFor is the single tier producer. Canonical paths are read from +// their watcher; checkout routes retain their own scoped receipt authority. +func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []string) relationFreshness { + label := relationFreshness{} + inScope := s.relationScope(ctx, ids, files) + states := make(map[string]string) + watcher, watched := s.currentWatcher().(interface { + TierPaths() map[string]indexer.MutationTierState + }) + view := requestViewFromContext(ctx) + if watched && (view == nil || !view.readsOwnCheckout()) { + for path, tiers := range watcher.TierPaths() { + if len(ids)+len(files) > 0 && !inScope(path) { + continue + } + at := tiers.RelationsAt + if scope == "file" { + at = tiers.FileGraphAt + } else if scope == "derived" { + at = tiers.DerivedAt + } + state := "" + if tiers.Failed { + state = "failed" + } else if at.IsZero() || scope == "derived" && tiers.Partial { + state = "pending" + } + if state != "" { + states[s.repoRelative(path)] = state + } + } + } else { + for _, receipt := range s.laggingMutationReceipts(ctx) { + if !inScope(receipt.path) { + continue + } + state := "failed" + if receipt.outcome(true).Pending { + state = "pending" + } + states[s.repoRelative(receipt.path)] = state + } + } + for path, state := range states { + label.files = append(label.files, path) + if label.state != "failed" { + label.state = state + } + } + slices.Sort(label.files) + if label.state == "" && scope != "file" && s.multiIndexer != nil && s.multiIndexer.RelationsPending() { + label.state = "pending" + } + return label +} + +func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, files []string) { + if file := targetRepoFileRaw(name, req); file != "" { + return "file", nil, []string{file} + } + switch name { + case "get_callers", "find_usages", "get_call_chain", "get_dependencies", "get_dependents", "find_implementations", "walk_graph": + for _, key := range []string{"id", "symbol_id", "start_id"} { + if id := req.GetString(key, ""); id != "" { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return "relations", ids, nil + } + } + return "", nil, nil +} + +func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp.CallToolRequest, res *mcp.CallToolResult) *mcp.CallToolResult { + scope, ids, files := freshnessTargets(name, req) + if scope != "" { + if selected := takeRequestFreshness(&req).scope; selected != "" { + scope = selected + } + label := s.freshnessFor(ctx, scope, ids, files) + if label.state != "" { + fields := make(map[string]any) + label.apply(fields) + fields["freshness_scope"] = scope + res = decorateResultWithFreshness(res, fields) + } + } + if rider := s.freshnessRiderFor(name, req); rider != nil { + return decorateResultWithFreshness(res, rider) + } + if isFreshnessListTool(name) { + return s.decorateListResultWithFreshness(res) + } + return res +} + +func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp.CallToolRequest, freshness requestFreshness) *mcp.CallToolResult { + view := requestViewFromContext(ctx) + if !freshness.requireFresh || view != nil && view.readsOwnCheckout() { + return nil + } + if _, ok := s.currentWatcher().(interface { + TierPaths() map[string]indexer.MutationTierState + }); !ok { + return nil + } + scope, ids, files := freshnessTargets(name, req) + if scope == "" { + return nil + } + if freshness.scope != "" { + scope = freshness.scope + } + deadline := freshness.effectiveDeadline(time.Now(), ctx) + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + label := s.freshnessFor(ctx, scope, ids, files) + if label.state == "" { + return nil + } + if label.state == "failed" || !time.Now().Before(deadline) { + return label.requireFreshError() + } + select { + case <-ticker.C: + case <-ctx.Done(): + return label.requireFreshError() + } + } +} + +func (f relationFreshness) apply(result map[string]any) { + if f.state == "" { + return + } + result["complete"] = false + result["graph_freshness"] = f.state + result["freshness_scope"] = "relations" + result["pending_file_count"] = len(f.files) + result["pending_files"] = f.files[:min(maxLabelledPendingFiles, len(f.files))] + if f.state == "failed" { + result["recovery_hint"] = mutationFreshnessRecoveryHint() + } +} + +func (f relationFreshness) decorate(res *mcp.CallToolResult) *mcp.CallToolResult { + if f.state == "" || res == nil || res.IsError { + return res + } + body, ok := singleTextContent(res) + if !ok || strings.HasPrefix(strings.TrimSpace(body), "{") { + return res + } + fields := make(map[string]any) + f.apply(fields) + return renderFreshnessLine(res, fields) +} + +func appendSourceFreshness(fields map[string]any, view *requestView) { + fields["graph_freshness"] = "unknown" + if view.sourceGraphPending { + fields["graph_freshness"] = "pending" + } +} + +func attachReceiptTiers(payload map[string]any, receipt *mutationReceipt) { + if receipt == nil || receipt.ticket == nil { + return + } + tiers := receipt.ticket.TierState() + if tiers.AckAt.IsZero() { + return + } + for key, at := range map[string]time.Time{ + "ack_at": tiers.AckAt, "file_graph_at": tiers.FileGraphAt, + "relations_at": tiers.RelationsAt, "derived_at": tiers.DerivedAt, + } { + if !at.IsZero() { + payload[key] = at.UTC().Format(time.RFC3339Nano) + } + } +} + // shortFreshSHA trims a git SHA to 12 chars for the rider. func shortFreshSHA(sha string) string { if len(sha) > 12 { diff --git a/internal/mcp/freshness_tiers_test.go b/internal/mcp/freshness_tiers_test.go index 5bb402c5c..92a87d11a 100644 --- a/internal/mcp/freshness_tiers_test.go +++ b/internal/mcp/freshness_tiers_test.go @@ -2,11 +2,21 @@ package mcp import ( "context" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "sync" "testing" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" ) func TestRestartLostReceiptsReturnWithoutWaiting(t *testing.T) { @@ -28,6 +38,88 @@ func TestRestartLostReceiptsReturnWithoutWaiting(t *testing.T) { } } +func TestFreshnessFieldsHaveOneProducer(t *testing.T) { + entries, err := os.ReadDir(".") + require.NoError(t, err) + keys := map[string]bool{"graph_freshness": true, "file_graph_at": true, "relations_at": true, "derived_at": true} + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "freshness_rider.go" { + continue + } + file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + require.NoError(t, err) + ast.Inspect(file, func(node ast.Node) bool { + if literal, ok := node.(*ast.BasicLit); ok && literal.Kind == token.STRING { + key, _ := strconv.Unquote(literal.Value) + require.False(t, keys[key], "%s produces tier field %s outside the shared rider", name, key) + } + return true + }) + } +} + +type watchedTierFixture struct { + mutationTestWatcher + mu sync.RWMutex + paths map[string]indexer.MutationTierState +} + +func (w *watchedTierFixture) TierPaths() map[string]indexer.MutationTierState { + w.mu.RLock() + defer w.mu.RUnlock() + copyPaths := make(map[string]indexer.MutationTierState, len(w.paths)) + for path, state := range w.paths { + copyPaths[path] = state + } + return copyPaths +} + +func TestRequireFreshWaitsForRelationsOnWatchedPrimary(t *testing.T) { + s, root := newRelationFixture(t) + path := filepath.Join(root, "caller.go") + watcher := &watchedTierFixture{paths: map[string]indexer.MutationTierState{ + path: {Generation: 1, AckAt: time.Now(), FileGraphAt: time.Now()}, + }} + s.SetWatcher(watcher) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "freshness_scope": "relations"} + finished := make(chan *mcp.CallToolResult, 1) + go func() { + finished <- s.awaitWatchedFreshness(t.Context(), req.Params.Name, req, takeRequestFreshness(&req)) + }() + select { + case <-finished: + t.Fatal("file publication incorrectly certified pending relations") + case <-time.After(35 * time.Millisecond): + } + watcher.mu.Lock() + state := watcher.paths[path] + state.RelationsAt = time.Now() + watcher.paths[path] = state + watcher.mu.Unlock() + select { + case refusal := <-finished: + require.Nil(t, refusal, "relations are visible even while derived is unfinished") + case <-time.After(time.Second): + t.Fatal("relations publication did not release the primary wait") + } + require.Empty(t, s.freshnessFor(t.Context(), "relations", []string{targetID(t, s)}, nil).state) + require.Equal(t, "pending", s.freshnessFor(t.Context(), "derived", []string{targetID(t, s)}, nil).state) +} + +func TestFailedPatchLabelsOnlyItsFile(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "other.go"): {Generation: 1, Failed: true}, + }}) + require.Empty(t, s.freshnessFor(t.Context(), "relations", []string{targetID(t, s)}, nil).state) + label := s.freshnessFor(t.Context(), "relations", nil, []string{"other.go"}) + require.Equal(t, "failed", label.state) + require.Equal(t, []string{"other.go"}, label.files) +} + func TestFreshnessRiderLabelsEveryOutputMode(t *testing.T) { rider := map[string]any{"freshness_scope": "relations", "graph_freshness": "pending", "pending_file_count": 2, "pending_files": []string{"caller.go", "target.go"}} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 09ec77aec..baaeb22dd 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -276,6 +276,9 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // Publishing is a no-op when no firewall frame is above us (an // unbounded call). noteRetainedRequest(ctx, view, scope) + if refusal := s.awaitWatchedFreshness(ctx, legacyName, req, freshness); refusal != nil { + return refusal, nil + } if requireExactView && view != nil && view.rider != nil && !view.rider.Exact { return mcp.NewToolResultError(graphview.NewViewError(graphview.CodeViewBuilding, "the requested exact checkout view is unavailable; retry after publication; no fallback was served").Error()), nil @@ -435,13 +438,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // small `freshness` block so the agent knows the graph view may lag // the working tree. Omitted (zero cost) for the common fresh case. if hErr == nil { - if rider := s.freshnessRiderFor(req.Params.Name, req); rider != nil { - res = decorateResultWithFreshness(res, rider) - } else if isFreshnessListTool(req.Params.Name) { - // List tools get a per-file sweep: any hit whose file drifted - // or vanished on disk is flagged with per-repo provenance. - res = s.decorateListResultWithFreshness(res) - } + res = s.decorateToolFreshness(ctx, req.Params.Name, req, res) // Which view answered rides in that same block: a response that // came from somewhere other than the base — or fell back to it — // must say so where the caller already looks for provenance. diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index 1b489e247..33ce0fb31 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -9,7 +9,6 @@ import ( "strings" "time" - wire "github.com/gortexhq/gcx-go" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" @@ -57,51 +56,29 @@ func relationWaitDeadline(ctx context.Context, freshness requestFreshness) time. // the scope are neither waited on nor reported. func (s *Server) awaitRelationFreshness(ctx context.Context, ids, files []string, deadline time.Time) relationFreshness { started := time.Now() - lagging := s.laggingMutationReceipts(ctx) - if len(lagging) == 0 { - return relationFreshness{waited: time.Since(started)} - } - inScope := s.relationScope(ctx, ids, files) - if remaining := time.Until(deadline); remaining > 0 { - timer := time.NewTimer(remaining) - defer timer.Stop() - wait: - for _, receipt := range lagging { - if !inScope(receipt.path) { + label := s.freshnessFor(ctx, "relations", ids, files) + if label.state == "" || label.state == "failed" || !deadline.After(started) { + label.waited = time.Since(started) + return label + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + for { + select { + case <-ticker.C: + label = s.freshnessFor(ctx, "relations", ids, files) + if label.state != "" && label.state != "failed" { continue } - select { - case <-receipt.done: - case <-timer.C: - break wait - case <-ctx.Done(): - break wait - } - } - lagging = s.laggingMutationReceipts(ctx) - } - - states := make(map[string]string) - for _, receipt := range lagging { - if !inScope(receipt.path) { - continue - } - key := s.repoRelative(receipt.path) - if receipt.outcome(true).Pending { - states[key] = "pending" - } else if states[key] == "" { - states[key] = "failed" + case <-timer.C: + label = s.freshnessFor(ctx, "relations", ids, files) + case <-ctx.Done(): } + label.waited = time.Since(started) + return label } - label := relationFreshness{waited: time.Since(started)} - for path, state := range states { - label.files = append(label.files, path) - if label.state != "failed" { - label.state = state - } - } - sort.Strings(label.files) - return label } // laggingMutationReceipts returns the request's mutation receipts whose edit @@ -198,22 +175,6 @@ func namesAnyIdentifier(data []byte, names []string) bool { return false } -// apply writes the label into an answer's fields. A fresh scope writes -// nothing, so an answer with nothing lagging keeps its shape. -func (f relationFreshness) apply(result map[string]any) { - if f.state == "" { - return - } - result["complete"] = false - result["graph_freshness"] = f.state - result["freshness_scope"] = "relations" - result["pending_file_count"] = len(f.files) - result["pending_files"] = f.files[:min(maxLabelledPendingFiles, len(f.files))] - if f.state == "failed" { - result["recovery_hint"] = mutationFreshnessRecoveryHint() - } -} - // line is the relations line a text answer leads with. func (f relationFreshness) line() string { return fmt.Sprintf("relations: %s (%d files)", f.state, len(f.files)) @@ -252,30 +213,3 @@ func (f relationFreshness) merge(other relationFreshness) relationFreshness { sort.Strings(f.files) return f } - -// decorate makes a rendered text answer lead with its label: TOON and plain -// text start with the relations line; GCX keeps GCX1 as line 1, carries the -// label as header meta and the relations line as a comment on line 2. JSON -// already carries the label in its fields and passes through unchanged. -func (f relationFreshness) decorate(res *mcp.CallToolResult) *mcp.CallToolResult { - if f.state == "" || res == nil || res.IsError || len(res.Content) == 0 { - return res - } - text, ok := res.Content[0].(mcp.TextContent) - if !ok || strings.HasPrefix(strings.TrimSpace(text.Text), "{") { - return res - } - body := text.Text - if strings.HasPrefix(body, wire.Tag) { - body, _ = injectGCXHeaderMeta(body, map[string]any{ - "graph_freshness": f.state, - "freshness_scope": "relations", - "pending_file_count": len(f.files), - }) - head, rows, _ := strings.Cut(body, wire.RowSep) - body = head + wire.RowSep + wire.CommentPrefix + " " + f.line() + wire.RowSep + rows - } else { - body = f.line() + "\n" + body - } - return rebuildTextResult(res, body) -} diff --git a/internal/mcp/view_request.go b/internal/mcp/view_request.go index 8b4d13a1f..fe3bab697 100644 --- a/internal/mcp/view_request.go +++ b/internal/mcp/view_request.go @@ -2597,10 +2597,7 @@ func viewRiderFields(view *requestView) map[string]any { fields["freshness_scope"] = view.sourceScope fields["checkout_incarnation"] = view.sourceCheckoutIncarnation fields["capability_scope"] = view.sourceScope - fields["graph_freshness"] = "unknown" - if view.sourceGraphPending { - fields["graph_freshness"] = "pending" - } + appendSourceFreshness(fields, view) if len(view.sourcePendingPaths) > 0 { fields["pending_file_count"] = len(view.sourcePendingPaths) fields["pending_files"] = view.sourcePendingPaths[:min(16, len(view.sourcePendingPaths))] From 11e7bc39d92b9e3d213bad95345e75e76dee71e9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:45:09 +0100 Subject: [PATCH 024/138] cli: expose required freshness and exit status four --- cmd/gortex/call.go | 4 ++ cmd/gortex/cli_freshness.go | 66 ++++++++++++++++++++++++++++++++ cmd/gortex/cli_freshness_test.go | 39 +++++++++++++++++++ cmd/gortex/query.go | 4 ++ cmd/gortex/root.go | 1 + 5 files changed, 114 insertions(+) create mode 100644 cmd/gortex/cli_freshness.go create mode 100644 cmd/gortex/cli_freshness_test.go diff --git a/cmd/gortex/call.go b/cmd/gortex/call.go index 7546348b3..401fe8792 100644 --- a/cmd/gortex/call.go +++ b/cmd/gortex/call.go @@ -100,6 +100,7 @@ func runCall(cmd *cobra.Command, args []string) error { if err != nil { return err } + argObj = applyCLIRequireFresh(argObj) compactCall := gortexmcp.IsFacadeToolName(tool) && !callLegacy formatExplicit := callFormat != "json" @@ -144,6 +145,9 @@ func runCall(cmd *cobra.Command, args []string) error { relay = callFacadeDaemonTool } raw, err := relay(callIndex, tool, argObj) + if cliRequireFresh { + err = checkCLIFreshness(cmd, raw, err) + } if err != nil { return err } diff --git a/cmd/gortex/cli_freshness.go b/cmd/gortex/cli_freshness.go new file mode 100644 index 000000000..e99faf7c8 --- /dev/null +++ b/cmd/gortex/cli_freshness.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var cliRequireFresh bool + +func applyCLIRequireFresh(args map[string]any) map[string]any { + if cliRequireFresh { + if args == nil { + args = make(map[string]any) + } + args["require_fresh"] = true + } + return args +} + +func checkCLIFreshness(cmd *cobra.Command, raw json.RawMessage, callErr error) error { + var fields map[string]any + _ = json.Unmarshal(raw, &fields) + if nested, ok := fields["freshness"].(map[string]any); ok { + fields = nested + } + state, _ := fields["graph_freshness"].(string) + scope, _ := fields["freshness_scope"].(string) + line := "" + if state != "" && state != "fresh" { + if scope == "" { + scope = "relations" + } + count := fields["pending_file_count"] + if count == nil { + count = 0 + } + line = fmt.Sprintf("%s: %s (%v files)", scope, state, count) + } else { + text := string(raw) + if callErr != nil { + text = callErr.Error() + } + for _, tier := range []string{"file", "relations", "derived"} { + for _, status := range []string{"pending", "failed", "unknown"} { + prefix := tier + ": " + status + " (" + if _, tail, ok := strings.Cut(text, prefix); ok { + if count, _, closed := strings.Cut(tail, " files)"); closed { + line = prefix + count + " files)" + } + } + } + } + } + if line == "" { + return callErr + } + fmt.Fprintln(cmd.ErrOrStderr(), line) + if cliRequireFresh { + cmd.SilenceErrors = true + return &exitCodeError{code: 4} + } + return callErr +} diff --git a/cmd/gortex/cli_freshness_test.go b/cmd/gortex/cli_freshness_test.go new file mode 100644 index 000000000..dbf9b81fe --- /dev/null +++ b/cmd/gortex/cli_freshness_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "bytes" + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestCLIRequireFreshPrintsLineAndExits(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + for _, nested := range []bool{false, true} { + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + body := []byte(`{"freshness_scope":"relations","graph_freshness":"pending","pending_file_count":2}`) + if nested { + body = append(append([]byte(`{"freshness":`), body...), '}') + } + err := checkCLIFreshness(cmd, body, nil) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, "relations: pending (2 files)\n", stderr.String()) + } + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + err := checkCLIFreshness(cmd, nil, errors.New("require_fresh: relations: failed (1 files) after waiting 25 ms")) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, "relations: failed (1 files)\n", stderr.String()) + require.Equal(t, true, applyCLIRequireFresh(nil)["require_fresh"]) +} diff --git a/cmd/gortex/query.go b/cmd/gortex/query.go index 8ddb1becc..90ff51b2f 100644 --- a/cmd/gortex/query.go +++ b/cmd/gortex/query.go @@ -53,6 +53,7 @@ func requireDaemonTool(repoPath, tool string, args map[string]any) (json.RawMess // surface without changing daemon-global configuration. It shares the normal // daemon-required and repo-not-tracked error behavior. func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, tools, toolsMode string) (json.RawMessage, error) { + args = applyCLIRequireFresh(args) exec, err := resolveExecutorWithToolSurface(repoPath, tools, toolsMode) if err != nil { if errors.Is(err, ErrNoExecutor) { @@ -62,6 +63,9 @@ func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, to } defer exec.Close() out, err := callToolWaitingForLoad(exec, tool, args, effectiveLoadingWait()) + if freshnessErr := checkCLIFreshness(rootCmd, out, err); freshnessErr != nil { + return nil, freshnessErr + } if err != nil { if errors.Is(err, ErrRepoNotTracked) { return nil, daemonRequiredErr(repoPath) diff --git a/cmd/gortex/root.go b/cmd/gortex/root.go index d21856d43..9ddd4f546 100644 --- a/cmd/gortex/root.go +++ b/cmd/gortex/root.go @@ -167,6 +167,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default .gortex.yaml)") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level: debug|info|warn|error") rootCmd.PersistentFlags().BoolVar(&noProgress, "no-progress", false, "disable animated progress output (also honored: NO_COLOR, TERM=dumb, CI, non-TTY stderr)") + rootCmd.PersistentFlags().BoolVar(&cliRequireFresh, "require-fresh", false, "wait for graph freshness; exit 4 if the requested tier remains pending or failed") } func newLoggerConfig() zap.Config { From 85d35db56e19058bc4fd356112b4120d274e9ed3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:45:09 +0100 Subject: [PATCH 025/138] indexer: keep restart relations pending until successful reconciliation --- cmd/gortex/daemon_state.go | 10 ++++++++-- internal/indexer/multi.go | 2 ++ internal/indexer/mutation_tiers.go | 8 ++++++++ internal/indexer/mutation_tiers_test.go | 10 ++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cmd/gortex/daemon_state.go b/cmd/gortex/daemon_state.go index 2d7d81663..8fd2c8b78 100644 --- a/cmd/gortex/daemon_state.go +++ b/cmd/gortex/daemon_state.go @@ -1313,8 +1313,11 @@ func warmupDaemonStateContext(ctx context.Context, state *daemonState, logger *z logger.Info("daemon: reconciled post-batch contract edges", zap.Int("contract_edges", reconciled)) case warmGlobalResolveFull: - if err := state.multiIndexer.RunGlobalResolveContext(ctx); err != nil && ctx.Err() == nil { - logger.Warn("daemon: warmup post-batch resolve incomplete", zap.Error(err)) + if err := state.multiIndexer.RunGlobalResolveContext(ctx); err != nil { + resolveOK = false + if ctx.Err() == nil { + logger.Warn("daemon: warmup post-batch resolve incomplete", zap.Error(err)) + } } logger.Info("daemon: post-batch workspace eligibility change forced full cross-repo catch-up") } @@ -1360,6 +1363,9 @@ func warmupDaemonStateContext(ctx context.Context, state *daemonState, logger *z if ctx.Err() != nil { return nil, timings } + if resolveOK { + state.multiIndexer.CompleteStartupRelations() + } state.basePublisher.BeginDraining() finishWatchers := startWarmupStep(logger, "watcher_start") diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 41f2170c9..eb1f8a0fe 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -86,6 +86,7 @@ type repositoryUntrackState struct { // MultiIndexer orchestrates indexing across multiple repositories. type MultiIndexer struct { graph graph.Store + relationsPending atomic.Bool // restart batch has not published its shared resolver tail contractCoreRuntime atomic.Pointer[ContractCoreRuntimeHooks] // contractSources caches the accepted bytes each journal fingerprinted, // by source fingerprint, for contract analysis reads. @@ -557,6 +558,7 @@ func (mi *MultiIndexer) BeginBatch() { // EndBatch; call RunDeferredPassesAllResult between the parallel parse and // EndBatch to run the deferred per-repo passes serially. func (mi *MultiIndexer) BeginParallelBatch() { + mi.relationsPending.Store(true) mi.batchMutationGate.Lock() defer mi.batchMutationGate.Unlock() diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index c3d324e72..6340b75a6 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -175,6 +175,10 @@ func (mw *MultiWatcher) TierState(path string) MutationTierState { return MutationTierState{} } +// RelationsPending reports a restarted workspace's shared resolver boundary. +// The early serving boundary can precede cross-repository reference binding. +func (mi *MultiIndexer) RelationsPending() bool { return mi.relationsPending.Load() } + // TierState reads a ticket's live stamps; the ticket survives waiter removal. func (t *MutationTicket) TierState() MutationTierState { if t == nil { @@ -184,3 +188,7 @@ func (t *MutationTicket) TierState() MutationTierState { defer t.tierMu.RUnlock() return t.tiers } + +// CompleteStartupRelations is called only after successful warmup resolution. +// ResetBatch also serves cancelled warmup and cannot certify this boundary. +func (mi *MultiIndexer) CompleteStartupRelations() { mi.relationsPending.Store(false) } diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index f5b9ec642..67dbc9fa2 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -48,3 +48,13 @@ func TestIdentityPatchPublishesTicketTiers(t *testing.T) { require.False(t, tiers.DerivedAt.IsZero(), "a successful identity no-op still certifies the admitted bytes") require.False(t, tiers.Failed) } + +func TestStartupRelationsRemainPendingAfterCancelledBatchReset(t *testing.T) { + mi := &MultiIndexer{} + mi.BeginParallelBatch() + require.True(t, mi.RelationsPending()) + mi.ResetBatch() // also used when warmup fails or is cancelled + require.True(t, mi.RelationsPending(), "an aborted batch cannot certify startup relations") + mi.CompleteStartupRelations() + require.False(t, mi.RelationsPending()) +} From bc44288aaaba212fb624268d5373c30e8fd10615 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:46:37 +0100 Subject: [PATCH 026/138] fix: preserve CLI routing errors and combine freshness provenance --- cmd/gortex/query.go | 4 +++- internal/mcp/freshness_rider.go | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/gortex/query.go b/cmd/gortex/query.go index 90ff51b2f..9b5441b93 100644 --- a/cmd/gortex/query.go +++ b/cmd/gortex/query.go @@ -64,7 +64,9 @@ func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, to defer exec.Close() out, err := callToolWaitingForLoad(exec, tool, args, effectiveLoadingWait()) if freshnessErr := checkCLIFreshness(rootCmd, out, err); freshnessErr != nil { - return nil, freshnessErr + if _, coded := freshnessErr.(*exitCodeError); coded { + return nil, freshnessErr + } } if err != nil { if errors.Is(err, ErrRepoNotTracked) { diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 8ab79af10..9f40fb8c1 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -504,10 +504,13 @@ func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) if json.Unmarshal([]byte(text), &asObj) != nil { return renderFreshnessLine(res, rider) } - if _, exists := asObj["freshness"]; exists { - return res + if existing, ok := asObj["freshness"].(map[string]any); ok { + for key, value := range rider { + existing[key] = value + } + } else { + asObj["freshness"] = rider } - asObj["freshness"] = rider body, err := json.Marshal(asObj) if err != nil { return res From 4045c12c22736f7509c448577fb8414894a4444c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:46:37 +0100 Subject: [PATCH 027/138] docs: teach publication tiers and exact readback guidance --- docs/mcp.md | 25 +++++++++++++++++++ internal/mcp/guide.go | 2 ++ internal/profiles/bodies.go | 17 +------------ .../profiles/routing_freshness_policy_test.go | 7 +++++- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index df13918d7..552a27ef2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -472,6 +472,31 @@ the first attests *what bytes* are on disk, the second whether the write happened at all. A call abandoned before its response returns no evidence block — only the receipt — which is exactly when `disk_status` is the field you need. +### Publication tiers and freshness + +`readback_exact:true` means the written bytes can be used immediately; continue +without polling. Graph relations and derived analyses can publish later. Check +`graph_freshness` before acting on callers, usages, impact, or rename plans. +Before a rename, deletion, or signature change, send `require_fresh:true`, +`freshness_scope:"relations"`, and an absolute RFC3339 `wait_deadline`. +`freshness_scope` accepts `file`, `relations`, and `derived` as request metadata; +it is intentionally absent from tool schemas. Text and declaration capability +scopes do not select a publication tier. + +Pending or failed answers carry a visible tier line in text/TOON/compact output; +GCX preserves its `GCX1` header and places the line immediately after it. JSON +carries the same fields in its freshness block. An idle fresh answer gains no +fields. Never classify a symbol as dead while its file is pending. + +`mutation_status` reads live `ack_at`, `file_graph_at`, `relations_at`, and +`derived_at` stamps when a watcher ticket observed them. Missing stamps mean +unobserved work. Unknown receipt or mutation IDs return `state:"lost"` immediately; +a lost receipt cannot certify freshness. After restart, relation queries remain +pending until the shared startup resolver has published. File reads still check +current disk state. CLI verbs print the freshness line on stderr; +`--require-fresh` exits with code 4 when the requested tier remains pending or +failed. + ## Agent-optimized (token efficiency) | Tool | Description | diff --git a/internal/mcp/guide.go b/internal/mcp/guide.go index e14d20036..580363626 100644 --- a/internal/mcp/guide.go +++ b/internal/mcp/guide.go @@ -127,6 +127,8 @@ File reads of a live checkout verify current source bytes independently of relat The session CWD selects an automatically discovered checkout overlay. Do not call track for an ordinary worktree: explicit tracking means the user requested a dedicated logical graph. Overall is one coherent view—the selected overlay plus its designated primary—not a union of branches. Overlay data only wins for duplicate logical identities; tombstones hide deleted base identities. +A readback_exact:true acknowledgement means continue without polling. Check graph_freshness before acting on callers, usages, impact, or a rename plan. Before rename, delete, or signature changes, use require_fresh:true with freshness_scope:"relations" and wait_deadline. A symbol whose file is pending must not be treated as dead. Watched primary checkouts wait for the requested file, relations, or derived tier; immutable ref/commit views cannot advance. + Every response carries freshness metadata. ` + "`exact:false`" + ` means the requested view was not served, and every fallback is read-only. Set ` + "`require_exact:true`" + ` to reject fallback, ` + "`require_fresh:true`" + ` to wait for current filesystem state, and bound waiting with an absolute RFC3339 ` + "`wait_deadline`" + ` (a malformed or past deadline is refused). ` + "`require_fresh`" + ` on graph-dependent operations waits for the selected checkout's route to reach the working copy as the coordinator samples it now, and reports what the wait did: ` + "`fresh`" + `, ` + "`waited_ms`" + `, ` + "`wait_deadline`" + `, and ` + "`fresh_reason`" + ` when it is false. ` + "`fresh:true`" + ` is reported only when the answer is read through the very route the wait published — the same checkout, served exactly, at the same route epoch; anything else answers ` + "`fresh:false`" + `. The ` + "`fresh_reason`" + ` vocabulary is ` + "`deadline_exceeded`" + ` (your bound expired with the route still behind the working copy), ` + "`interrupted`" + ` (the request ended first), ` + "`refresh_admission_abandoned`" + ` (the coordinator gave up on a bound of its own before admitting any refresh, so a longer wait_deadline is not the fix), ` + "`coordinator_unavailable`" + ` (this server can publish no checkout generation at all), ` + "`checkout_root_changed`" + ` and ` + "`checkout_refresh_stopped`" + ` (this one checkout moved, or its coordinator stopped), ` + "`publication_failed`" + `, ` + "`route_withdrawn`" + ` (publication succeeded and something else answered: a base fallback, another checkout, or a route that moved again), ` + "`committed_base_advance_unimplemented`" + `, and ` + "`wait_target_unavailable`" + ` (the route could not be resolved to wait on, or read back afterwards — a retry may succeed). diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 6dac1388d..0954be041 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -72,22 +72,7 @@ var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work; §re // actions. const WorktreeBranchRoutingPolicy = "- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph.\n" + "- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy.\n" + - // The `require_fresh` clause is qualified because the wait only advances a - // routed automatic checkout: a shared corpus, a dedicated/primary checkout - // and a labelled base/ref/commit view all answer fresh:false with a - // fresh_reason rather than waiting (internal/mcp/checkout_binding.go's - // freshReasonCommittedBaseAdvance). "absolute" is dropped from the - // deadline clause because RFC3339 names an absolute timestamp by - // definition; the refusal message and the views guide both still say it in - // full. - // - // Paying for that qualification out of this bullet alone left the core - // body 3 bytes under its ceiling, which is a ceiling that cannot absorb - // the next correction. The elaboration around every section of the body - // was trimmed instead — never a rule, a tool name or a contract — so the - // ceilings in profiles_test.go keep real head-room and none of them is - // re-based upward. - "- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`.\n" + + "- Freshness: `exact:false` is read-only substitution; `require_exact:true` refuses it. `readback_exact:true`: continue without polling. Check `graph_freshness`; pending files are not dead. Before rename/delete/signature edits use `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit views stay `fresh:false`.\n" + "- Select explicitly when CWD is not the target: `view:{kind:\"worktree\",checkout_id:\"…\"}` or `view:{kind:\"git_ref\",value:\"refs/heads/release\",graph_id:\"…\"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not.\n" + "- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" diff --git a/internal/profiles/routing_freshness_policy_test.go b/internal/profiles/routing_freshness_policy_test.go index 6a753ccfa..7d7d3fbb1 100644 --- a/internal/profiles/routing_freshness_policy_test.go +++ b/internal/profiles/routing_freshness_policy_test.go @@ -38,7 +38,12 @@ func TestRoutingPolicyQualifiesTheFreshnessWait(t *testing.T) { // The qualification itself: what require_fresh waits for, and what the // rest of the view space gets instead. for _, required := range []string{ - "routed automatic checkouts only", + "watched primaries", + "routed automatic checkouts", + "ref/commit views", + "readback_exact:true", + "freshness_scope:\"relations\"", + "pending files are not dead", "fresh:false", } { if !strings.Contains(policy, required) { From c59e1fff613a0f483031654fdccac36ca33d36b6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:49:38 +0100 Subject: [PATCH 028/138] mcp: expose per-tier receipt state beside ordered stamps --- internal/mcp/freshness_rider.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 9f40fb8c1..d379bccd1 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -738,6 +738,20 @@ func attachReceiptTiers(payload map[string]any, receipt *mutationReceipt) { payload[key] = at.UTC().Format(time.RFC3339Nano) } } + states := make(map[string]string, 3) + for name, at := range map[string]time.Time{"file_graph": tiers.FileGraphAt, "relations": tiers.RelationsAt, "derived": tiers.DerivedAt} { + state := "pending" + if !at.IsZero() { + state = "fresh" + } + if tiers.Failed { + state = "failed" + } else if name == "derived" && tiers.Partial { + state = "partial" + } + states[name] = state + } + payload["tier_state"] = states } // shortFreshSHA trims a git SHA to 12 chars for the rider. From cd40fb1bfc0fd7e84a94d7885cf53e5f49723243 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:52:32 +0100 Subject: [PATCH 029/138] mcp: retain pending publication evidence across relation reads --- internal/mcp/freshness_rider.go | 40 +++++++++++++++++++++++++----- internal/mcp/overlay.go | 1 + internal/mcp/relation_freshness.go | 15 +++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 0e6f4a503..4ec54c4d1 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -575,15 +575,14 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } slices.Sort(label.files) label.files = slices.Compact(label.files) + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok && snapshot.startup && scope != "file" && label.state == "" { + label.state = "pending" + } return label.merge(s.freshnessFor(ctx, scope, ids, files)) } states := make(map[string]string) - watcher, watched := s.currentWatcher().(interface { - TierPaths() map[string]indexer.MutationTierState - }) - view := requestViewFromContext(ctx) - if watched && (view == nil || !view.readsOwnCheckout()) { - for path, tiers := range watcher.TierPaths() { + if paths, watched := s.canonicalTierPaths(ctx); watched { + for path, tiers := range paths { if len(ids)+len(files) > 0 && !inScope(path) { continue } @@ -628,6 +627,32 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st return label } +func (s *Server) canonicalTierPaths(ctx context.Context) (map[string]indexer.MutationTierState, bool) { + watcher, watched := s.currentWatcher().(interface { + TierPaths() map[string]indexer.MutationTierState + }) + view := requestViewFromContext(ctx) + if !watched || view != nil && view.readsOwnCheckout() { + return nil, false + } + return watcher.TierPaths(), true +} + +type toolFreshnessSnapshotKey struct{} +type toolFreshnessSnapshot struct { + receipts []*mutationReceipt + startup bool +} + +func (s *Server) captureToolFreshness(ctx context.Context) context.Context { + receipts := s.laggingMutationReceipts(ctx) + startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() + if len(receipts) == 0 && !startup { + return ctx + } + return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{receipts, startup}) +} + func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, files []string) { if file := targetRepoFileRaw(name, req); file != "" { return "file", nil, []string{file} @@ -653,6 +678,9 @@ func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp scope = selected } label := s.freshnessFor(ctx, scope, ids, files) + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) + } if label.state != "" { fields := make(map[string]any) label.apply(fields) diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index baaeb22dd..eb3fa0e4f 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -357,6 +357,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withContractCoreReadErrors(ctx) } indexer.StampPublicationPhase(ctx, indexer.PublicationHandlerStarted) + ctx = s.captureToolFreshness(ctx) res, hErr := s.invokeAcknowledgedLeaf(ctx, req, h) if hErr == nil && res != nil && !res.IsError && lateExactnessRefusalIsSafe(req.Params.Name) { if err := contractCoreReadError(ctx); err != nil { diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index bfb4d12cd..7cb532ac5 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "sort" @@ -86,6 +87,20 @@ func (s *Server) awaitRelationFreshness(ctx context.Context, ids, files []string // the graph has not published: still pending, or terminally failed. func (s *Server) laggingMutationReceipts(ctx context.Context) []*mutationReceipt { var lagging []*mutationReceipt + if paths, watched := s.canonicalTierPaths(ctx); watched { + for path, tiers := range paths { + if !tiers.Failed && !tiers.RelationsAt.IsZero() { + continue + } + receipt := &mutationReceipt{path: path, generation: tiers.Generation} + if tiers.Failed { + receipt.completed = true + receipt.result.Err = errors.New("file publication failed") + } + lagging = append(lagging, receipt) + } + return lagging + } for _, receipt := range s.mutationReceiptsForRepos(ctx) { if outcome := receipt.outcome(true); outcome.Pending || outcome.Err != nil || !outcome.Reindexed { lagging = append(lagging, receipt) From ded4e0e1d085b3f3064e155cededc5f987a72be6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:54:39 +0100 Subject: [PATCH 030/138] mcp: honor tier waits through normalized facade requests --- internal/mcp/facade_tools.go | 4 +++ internal/mcp/freshness_rider.go | 63 +++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 00c7c9bb9..473f229ba 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -1169,6 +1169,10 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest forwarded.Params.Name = spec.Legacy forwarded.Params.Arguments = normalized forwarded.Params.RawArguments = nil + if refusal := s.awaitWatchedFreshness(ctx, spec.Legacy, forwarded, takeRequestFreshness(&forwarded)); refusal != nil { + return refusal, nil + } + ctx = s.captureToolFreshness(ctx) result, err = legacy.handler(ctx, forwarded) if err == nil { // Book the retrieval half of the savings ledger under the LEGACY tool diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 4ec54c4d1..793f7d772 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -560,32 +560,31 @@ func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.Ca func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []string, snapshots ...[]*mutationReceipt) relationFreshness { label := relationFreshness{} inScope := s.relationScope(ctx, ids, files) + type pathState struct { + state string + generation uint64 + } + states := make(map[string]pathState) + put := func(path, state string, generation uint64) { + if len(ids)+len(files) > 0 && !inScope(path) { + return + } + key := s.repoRelative(path) + if prior, exists := states[key]; !exists || generation >= prior.generation { + states[key] = pathState{state, generation} + } + } if len(snapshots) > 0 { for _, receipt := range snapshots[0] { - if !inScope(receipt.path) { - continue - } - label.files = append(label.files, s.repoRelative(receipt.path)) - if label.state != "failed" { - label.state = "pending" - if outcome := receipt.outcome(true); !outcome.Pending && (outcome.Err != nil || !outcome.Reindexed) { - label.state = "failed" - } + state := "pending" + if outcome := receipt.outcome(true); !outcome.Pending && (outcome.Err != nil || !outcome.Reindexed) { + state = "failed" } + put(receipt.path, state, receipt.generation) } - slices.Sort(label.files) - label.files = slices.Compact(label.files) - if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok && snapshot.startup && scope != "file" && label.state == "" { - label.state = "pending" - } - return label.merge(s.freshnessFor(ctx, scope, ids, files)) } - states := make(map[string]string) if paths, watched := s.canonicalTierPaths(ctx); watched { for path, tiers := range paths { - if len(ids)+len(files) > 0 && !inScope(path) { - continue - } at := tiers.RelationsAt if scope == "file" { at = tiers.FileGraphAt @@ -599,29 +598,32 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st state = "pending" } if state != "" { - states[s.repoRelative(path)] = state + put(path, state, tiers.Generation) } } } else { for _, receipt := range s.laggingMutationReceipts(ctx) { - if !inScope(receipt.path) { - continue - } state := "failed" if receipt.outcome(true).Pending { state = "pending" } - states[s.repoRelative(receipt.path)] = state + put(receipt.path, state, receipt.generation) } } for path, state := range states { label.files = append(label.files, path) if label.state != "failed" { - label.state = state + label.state = state.state } } slices.Sort(label.files) - if label.state == "" && scope != "file" && s.multiIndexer != nil && s.multiIndexer.RelationsPending() { + startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() + if len(snapshots) > 0 { + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + startup = startup || snapshot.startup + } + } + if label.state == "" && scope != "file" && startup { label.state = "pending" } return label @@ -648,6 +650,9 @@ func (s *Server) captureToolFreshness(ctx context.Context) context.Context { receipts := s.laggingMutationReceipts(ctx) startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() if len(receipts) == 0 && !startup { + if _, exists := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); exists { + return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{}) + } return ctx } return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{receipts, startup}) @@ -714,20 +719,24 @@ func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp if freshness.scope != "" { scope = freshness.scope } - deadline := freshness.effectiveDeadline(time.Now(), ctx) + started := time.Now() + deadline := freshness.effectiveDeadline(started, ctx) ticker := time.NewTicker(25 * time.Millisecond) defer ticker.Stop() for { label := s.freshnessFor(ctx, scope, ids, files) if label.state == "" { + annotateRequestFreshness(view, &requestFreshnessOutcome{fresh: true, deadline: deadline}, started) return nil } if label.state == "failed" || !time.Now().Before(deadline) { + label.waited = time.Since(started) return label.requireFreshError() } select { case <-ticker.C: case <-ctx.Done(): + label.waited = time.Since(started) return label.requireFreshError() } } From 943fa625d7acd9634daa737325e0fd926df769ff Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:56:22 +0100 Subject: [PATCH 031/138] test: enforce compact verification labels and idle response parity --- internal/mcp/freshness_rider.go | 23 +++++++++++++++++++++++ internal/mcp/freshness_tiers_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 793f7d772..37e892348 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -663,6 +663,29 @@ func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, return "file", nil, []string{file} } switch name { + case "verify_change": + var changes []struct { + SymbolID string `json:"symbol_id"` + } + if json.Unmarshal([]byte(req.GetString("changes", "")), &changes) == nil { + for _, change := range changes { + if change.SymbolID != "" { + ids = append(ids, change.SymbolID) + } + } + } + if len(ids) > 0 { + return "relations", ids, nil + } + case "get_edit_plan", "explain_change_impact", "check_guards": + for _, id := range strings.Split(req.GetString("ids", ""), ",") { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return "relations", ids, nil + } case "get_callers", "find_usages", "get_call_chain", "get_dependencies", "get_dependents", "find_implementations", "walk_graph": for _, key := range []string{"id", "symbol_id", "start_id"} { if id := req.GetString(key, ""); id != "" { diff --git a/internal/mcp/freshness_tiers_test.go b/internal/mcp/freshness_tiers_test.go index 92a87d11a..134883879 100644 --- a/internal/mcp/freshness_tiers_test.go +++ b/internal/mcp/freshness_tiers_test.go @@ -120,6 +120,34 @@ func TestFailedPatchLabelsOnlyItsFile(t *testing.T) { require.Equal(t, []string{"other.go"}, label.files) } +func TestVerifyChangeCompactCarriesFreshnessFirstLine(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "caller.go"): {Generation: 1, AckAt: time.Now()}, + }}) + req := mcp.CallToolRequest{} + req.Params.Name = "verify_change" + req.Params.Arguments = map[string]any{"changes": `[{"symbol_id":"` + targetID(t, s) + `","new_signature":"func Target()"}]`, "compact": true} + result, err := s.wrapToolHandler(s.handleVerifyChange)(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, resultText(result)) + require.True(t, strings.HasPrefix(resultText(result), "relations: pending (1 files)\n"), resultText(result)) +} + +func TestIdleFreshnessIsANoOp(t *testing.T) { + s, _ := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{}) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s)} + for _, body := range []string{`{"nodes":[]}`, "no callers", "callers[0]:"} { + original := mcp.NewToolResultText(body) + actual := s.decorateToolFreshness(t.Context(), req.Params.Name, req, original) + require.Same(t, original, actual) + require.Equal(t, body, resultText(actual)) + } +} + func TestFreshnessRiderLabelsEveryOutputMode(t *testing.T) { rider := map[string]any{"freshness_scope": "relations", "graph_freshness": "pending", "pending_file_count": 2, "pending_files": []string{"caller.go", "target.go"}} From 96456947914ad9f188fdcb50bdacab998e950493 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 17:58:34 +0100 Subject: [PATCH 032/138] mcp: preserve derived publication gaps during answer rendering --- internal/mcp/freshness_rider.go | 44 ++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 37e892348..a28f84f6d 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -574,7 +574,29 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st states[key] = pathState{state, generation} } } + putTiers := func(path string, tiers indexer.MutationTierState) { + at := tiers.RelationsAt + if scope == "file" { + at = tiers.FileGraphAt + } else if scope == "derived" { + at = tiers.DerivedAt + } + state := "" + if tiers.Failed { + state = "failed" + } else if at.IsZero() || scope == "derived" && tiers.Partial { + state = "pending" + } + if state != "" { + put(path, state, tiers.Generation) + } + } if len(snapshots) > 0 { + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + for path, tiers := range snapshot.paths { + putTiers(path, tiers) + } + } for _, receipt := range snapshots[0] { state := "pending" if outcome := receipt.outcome(true); !outcome.Pending && (outcome.Err != nil || !outcome.Reindexed) { @@ -585,21 +607,7 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } if paths, watched := s.canonicalTierPaths(ctx); watched { for path, tiers := range paths { - at := tiers.RelationsAt - if scope == "file" { - at = tiers.FileGraphAt - } else if scope == "derived" { - at = tiers.DerivedAt - } - state := "" - if tiers.Failed { - state = "failed" - } else if at.IsZero() || scope == "derived" && tiers.Partial { - state = "pending" - } - if state != "" { - put(path, state, tiers.Generation) - } + putTiers(path, tiers) } } else { for _, receipt := range s.laggingMutationReceipts(ctx) { @@ -643,19 +651,21 @@ func (s *Server) canonicalTierPaths(ctx context.Context) (map[string]indexer.Mut type toolFreshnessSnapshotKey struct{} type toolFreshnessSnapshot struct { receipts []*mutationReceipt + paths map[string]indexer.MutationTierState startup bool } func (s *Server) captureToolFreshness(ctx context.Context) context.Context { receipts := s.laggingMutationReceipts(ctx) + paths, _ := s.canonicalTierPaths(ctx) startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() - if len(receipts) == 0 && !startup { + if len(receipts) == 0 && len(paths) == 0 && !startup { if _, exists := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); exists { return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{}) } return ctx } - return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{receipts, startup}) + return context.WithValue(ctx, toolFreshnessSnapshotKey{}, toolFreshnessSnapshot{receipts: receipts, paths: paths, startup: startup}) } func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, files []string) { From 8974a382f698649cc9f2651b9b2369d957789d7e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:07:47 +0100 Subject: [PATCH 033/138] docs: keep freshness guidance within existing profile byte budgets --- internal/profiles/bodies.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 0954be041..3052290d4 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -70,11 +70,11 @@ var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work; §re // this constant rather than paraphrase it; routing is safety policy, and two // almost-equivalent copies eventually teach agents different destructive // actions. -const WorktreeBranchRoutingPolicy = "- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph.\n" + - "- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy.\n" + - "- Freshness: `exact:false` is read-only substitution; `require_exact:true` refuses it. `readback_exact:true`: continue without polling. Check `graph_freshness`; pending files are not dead. Before rename/delete/signature edits use `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit views stay `fresh:false`.\n" + - "- Select explicitly when CWD is not the target: `view:{kind:\"worktree\",checkout_id:\"…\"}` or `view:{kind:\"git_ref\",value:\"refs/heads/release\",graph_id:\"…\"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not.\n" + - "- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" +const WorktreeBranchRoutingPolicy = "- Session/CWD selects the view. Discovered checkouts, linked worktrees included, are an automatic overlay; do not `track` them. Only an explicit user request to track creates a dedicated graph.\n" + + "- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy.\n" + + "- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`.\n" + + "- Target another CWD: `view:{kind:\\\"worktree\\\",checkout_id:\\\"…\\\"}` or `view:{kind:\\\"git_ref\\\",value:\\\"refs/heads/release\\\",graph_id:\\\"…\\\"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not.\n" + + "- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" // sectionWorktreeViews teaches agents the ownership boundary that keeps // ordinary linked-worktree use cheap. It is shared by every profile so a lean From 54bd7c63caf4cf6a5c28dfac70ae202c28bb92ec Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:09:36 +0100 Subject: [PATCH 034/138] docs: retain routing cues while trimming profile wording --- internal/profiles/bodies.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 3052290d4..25149be7b 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -70,7 +70,7 @@ var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work; §re // this constant rather than paraphrase it; routing is safety policy, and two // almost-equivalent copies eventually teach agents different destructive // actions. -const WorktreeBranchRoutingPolicy = "- Session/CWD selects the view. Discovered checkouts, linked worktrees included, are an automatic overlay; do not `track` them. Only an explicit user request to track creates a dedicated graph.\n" + +const WorktreeBranchRoutingPolicy = "- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph.\n" + "- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy.\n" + "- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`.\n" + "- Target another CWD: `view:{kind:\\\"worktree\\\",checkout_id:\\\"…\\\"}` or `view:{kind:\\\"git_ref\\\",value:\\\"refs/heads/release\\\",graph_id:\\\"…\\\"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not.\n" + From 14c4aceb408a77d82ebbaa07163ec8734e448e36 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:08:45 +0100 Subject: [PATCH 035/138] test: pin freshness path and response tier contracts --- cmd/gortex/cli_freshness_content_test.go | 27 ++++++++ .../mcp/freshness_response_contract_test.go | 68 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 cmd/gortex/cli_freshness_content_test.go create mode 100644 internal/mcp/freshness_response_contract_test.go diff --git a/cmd/gortex/cli_freshness_content_test.go b/cmd/gortex/cli_freshness_content_test.go new file mode 100644 index 000000000..f254ab49b --- /dev/null +++ b/cmd/gortex/cli_freshness_content_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestCLIRequireFreshIgnoresRelationsLabelInsideSourceContent(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + body, err := json.Marshal(map[string]any{ + "path": "example.go", + "content": "package example\n// relations: pending (2 files)\nfunc Fresh() {}\n", + }) + require.NoError(t, err) + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + require.NoError(t, checkCLIFreshness(cmd, body, nil), "source text is not freshness metadata") + require.Empty(t, stderr.String(), "fresh source content must not produce a stale diagnostic") + require.False(t, cmd.SilenceErrors) +} diff --git a/internal/mcp/freshness_response_contract_test.go b/internal/mcp/freshness_response_contract_test.go new file mode 100644 index 000000000..a954c2d1e --- /dev/null +++ b/internal/mcp/freshness_response_contract_test.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestFreshnessResponseContractNormalizesFileReadPaths(t *testing.T) { + for _, tool := range []string{"read_file", "get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + s, root := newRelationFixture(t) + path := filepath.Join(root, "target.go") + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + path: {Generation: 1, AckAt: time.Now()}, + }}) + handlers := map[string]func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error){ + "read_file": s.handleReadFile, + "get_file_summary": s.handleGetFileSummary, + "get_editing_context": s.handleGetEditingContext, + } + for _, target := range []string{"target.go", path} { + t.Run(target, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Name = tool + req.Params.Arguments = map[string]any{"path": target, "format": "json"} + res, err := s.wrapToolHandler(handlers[tool])(t.Context(), req) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + payload := resultJSON(t, res) + freshness, ok := payload["freshness"].(map[string]any) + require.True(t, ok, "pending file read lacks freshness: %s", resultText(res)) + require.Equal(t, "pending", freshness["graph_freshness"]) + require.Equal(t, "file", freshness["freshness_scope"]) + require.EqualValues(t, 1, freshness["pending_file_count"]) + require.Equal(t, []any{"target.go"}, freshness["pending_files"]) + }) + } + }) + } +} + +func TestFreshnessResponseContractRequireFreshRefusalNamesSelectedTier(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + t.Run(scope, func(t *testing.T) { + s, root := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }}) + req := mcp.CallToolRequest{} + req.Params.Name = "read_file" + req.Params.Arguments = map[string]any{ + "path": "target.go", "require_fresh": true, "freshness_scope": scope, + "wait_deadline": time.Now().Add(50 * time.Millisecond).Format(time.RFC3339Nano), + } + res, err := s.wrapToolHandler(s.handleReadFile)(t.Context(), req) + require.NoError(t, err) + require.True(t, res.IsError, "pending %s tier must refuse require_fresh", scope) + require.Contains(t, resultText(res), "require_fresh: "+scope+": pending (1 files)") + }) + } +} From cb94b29701a3fc3d3a87beec13190c489ee0ad4e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:09:36 +0100 Subject: [PATCH 036/138] test: keep native storm and unrepaired path freshness observable --- internal/indexer/mutation_tiers_test.go | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index 67dbc9fa2..73ac1898d 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -1,6 +1,7 @@ package indexer import ( + "errors" "path/filepath" "testing" "time" @@ -34,6 +35,47 @@ func TestReceiptTierTimestampsAreOrdered(t *testing.T) { require.True(t, tiers.DerivedAt.IsZero(), "relations must publish before blocked derived work") } +func TestNativeOnlyStormHasFreshnessEvidence(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + path := filepath.Join(dir, "main.go") + watcher.config.StormQuietPeriodMs = int(time.Hour / time.Millisecond) + writeTestFile(t, path, "package main\nfunc After() {}\n") + watcher.recordInStormEvent(path, ChangeModified, true) + state, found := watcher.TierPaths()[path] + require.True(t, found, "a native-only storm edit must be visible before its drain") + require.False(t, state.AckAt.IsZero()) + require.True(t, state.RelationsAt.IsZero()) + watcher.completeStormMutationWaiters(map[string]uint64{path: state.Generation}, nil, errors.New("patch failed")) + require.True(t, watcher.TierState(path).Failed, "the native path must retain its terminal failure") + + repair, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case result := <-repair.Done: + require.NoError(t, result.Err) + case <-time.After(2 * time.Second): + t.Fatal("repair did not finish") + } + require.NotContains(t, watcher.TierPaths(), path, "successful newer content repairs the failed path") +} + +func TestExpiredReceiptCannotCertifyUnrepairedPath(t *testing.T) { + for _, partial := range []bool{false, true} { + t.Run(map[bool]string{false: "failed", true: "partial"}[partial], func(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + path := filepath.Join(dir, "main.go") + watcher.mu.Lock() + watcher.failedPaths = map[string]MutationTierState{path: { + Generation: 7, Failed: !partial, Partial: partial, failedAt: time.Now().Add(-11 * time.Minute), + }} + watcher.mu.Unlock() + state := watcher.TierState(path) + require.True(t, state.Failed || state.Partial, "receipt expiry is not content publication") + require.Contains(t, watcher.TierPaths(), path) + }) + } +} + func TestIdentityPatchPublishesTicketTiers(t *testing.T) { dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") ticket, err := watcher.EnqueueToolMutation(t.Context(), filepath.Join(dir, "main.go")) From 4e3a52ac2e9204724b77d9c5dabffc284364a27a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:11:52 +0100 Subject: [PATCH 037/138] indexer: track native storm tiers until content repairs failures --- internal/indexer/mutation_tiers.go | 11 ++--------- internal/indexer/watcher.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index 6340b75a6..a70558476 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -97,7 +97,8 @@ func (w *Watcher) finishMutationTierLocked(path string, ticket *MutationTicket, } } -// TierPaths returns only outstanding paths and retained per-path failures. +// TierPaths returns outstanding paths and unrepaired per-path failures. +// Receipt expiry is not content evidence; only a successful patch retires gaps. // It shares the waiter's existing path index; idle reads allocate no map. func (w *Watcher) TierPaths() map[string]MutationTierState { w.mu.Lock() @@ -112,10 +113,6 @@ func (w *Watcher) TierPaths() map[string]MutationTierState { } } for path, state := range w.failedPaths { - if time.Since(state.failedAt) >= 10*time.Minute { - delete(w.failedPaths, path) - continue - } put(path, state) } for path, byGeneration := range w.mutationWaiters { @@ -133,10 +130,6 @@ func (w *Watcher) TierState(path string) MutationTierState { defer w.mu.Unlock() path = filepath.Clean(path) state := w.failedPaths[path] - if !state.failedAt.IsZero() && time.Since(state.failedAt) >= 10*time.Minute { - delete(w.failedPaths, path) - state = MutationTierState{} - } for generation, waiter := range w.mutationWaiters[path] { if generation >= state.Generation && waiter.ticket != nil { state = waiter.ticket.TierState() diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 2380a8653..529fe09fb 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -2537,6 +2537,29 @@ func (w *Watcher) recordInStormEvent(path string, kind ChangeKind, native bool) w.stormGenerations[path] = generation } } + // A native-only storm path has no stopped point timer to adopt. Give it + // the same path generation and live ticket evidence as point admission. + if w.stormGenerations[path] == 0 { + w.nextGeneration++ + generation := w.nextGeneration + if w.stormGenerations == nil { + w.stormGenerations = make(map[string]uint64) + } + if w.pendingGeneration == nil { + w.pendingGeneration = make(map[string]uint64) + } + if w.mutationWaiters == nil { + w.mutationWaiters = make(map[string]map[uint64]mutationWaiter) + } + if w.mutationWaiters[path] == nil { + w.mutationWaiters[path] = make(map[uint64]mutationWaiter) + } + done := make(chan MutationResult, 1) + ticket := newMutationTierTicket(path, generation, done) + w.mutationWaiters[path][generation] = mutationWaiter{done: done, ticket: ticket} + w.pendingGeneration[path] = generation + w.stormGenerations[path] = generation + } w.mu.Unlock() w.stormBatch[path] = kind w.stormRetryAttempt = 0 From fe92b5cd86e4fcfc9d40f60bf75a0a28a27e7eda Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:11:52 +0100 Subject: [PATCH 038/138] fix: preserve freshness scope and distinguish source text from labels --- cmd/gortex/cli_freshness.go | 11 +++++++++-- internal/mcp/freshness_rider.go | 8 ++++++-- internal/mcp/relation_freshness.go | 21 ++++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/cmd/gortex/cli_freshness.go b/cmd/gortex/cli_freshness.go index e99faf7c8..baf837b1b 100644 --- a/cmd/gortex/cli_freshness.go +++ b/cmd/gortex/cli_freshness.go @@ -41,12 +41,19 @@ func checkCLIFreshness(cmd *cobra.Command, raw json.RawMessage, callErr error) e } else { text := string(raw) if callErr != nil { - text = callErr.Error() + var labelled bool + text, labelled = strings.CutPrefix(callErr.Error(), "require_fresh: ") + if !labelled { + return callErr + } + } else if json.Valid(raw) { + return nil // source strings inside JSON are not freshness metadata } + text, _, _ = strings.Cut(text, "\n") for _, tier := range []string{"file", "relations", "derived"} { for _, status := range []string{"pending", "failed", "unknown"} { prefix := tier + ": " + status + " (" - if _, tail, ok := strings.Cut(text, prefix); ok { + if tail, ok := strings.CutPrefix(text, prefix); ok { if count, _, closed := strings.Cut(tail, " files)"); closed { line = prefix + count + " files)" } diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index a28f84f6d..faa010070 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -558,7 +558,7 @@ func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.Ca // freshnessFor is the single tier producer. Canonical paths are read from // their watcher; checkout routes retain their own scoped receipt authority. func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []string, snapshots ...[]*mutationReceipt) relationFreshness { - label := relationFreshness{} + label := relationFreshness{scope: scope} inScope := s.relationScope(ctx, ids, files) type pathState struct { state string @@ -781,7 +781,11 @@ func (f relationFreshness) apply(result map[string]any) { } result["complete"] = false result["graph_freshness"] = f.state - result["freshness_scope"] = "relations" + scope := f.scope + if scope == "" { + scope = "relations" + } + result["freshness_scope"] = scope result["pending_file_count"] = len(f.files) result["pending_files"] = f.files[:min(maxLabelledPendingFiles, len(f.files))] if f.state == "failed" { diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index 7cb532ac5..9f580bc56 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" "time" @@ -27,6 +28,7 @@ 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 // "", "pending" or "failed" files []string // graph-keyed paths of the lagging in-scope edits, sorted waited time.Duration @@ -118,6 +120,16 @@ func (s *Server) laggingMutationReceipts(ctx context.Context) []*mutationReceipt func (s *Server) relationScope(ctx context.Context, ids, files []string) func(absPath string) bool { scoped := make(map[string]bool, len(files)+len(ids)) for _, file := range files { + if strings.HasPrefix(file, "file://") { + if abs, err := s.resolveOverlayAbsPath(file); err == nil { + file = abs + } + } + if filepath.IsAbs(file) { + file = s.repoRelative(file) + } else { + file = filepath.ToSlash(filepath.Clean(file)) + } scoped[file] = true } var names []string @@ -193,7 +205,11 @@ func namesAnyIdentifier(data []byte, names []string) bool { // line is the relations line a text answer leads with. func (f relationFreshness) line() string { - return fmt.Sprintf("relations: %s (%d files)", f.state, len(f.files)) + scope := f.scope + if scope == "" { + scope = "relations" + } + return fmt.Sprintf("%s: %s (%d files)", scope, f.state, len(f.files)) } // requireFreshError is the answer to a require_fresh call whose wait ended @@ -210,6 +226,9 @@ func (f relationFreshness) requireFreshError() *mcp.CallToolResult { // merge folds the label of another scope the same answer read into f: the // files of both, failed over pending, and the time spent waiting on each. func (f relationFreshness) merge(other relationFreshness) relationFreshness { + if f.scope == "" { + f.scope = other.scope + } f.waited += other.waited if other.state == "" { return f From 6e1a9639dab0f5b9d333d6850f5ae69e084582d2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:13:21 +0100 Subject: [PATCH 039/138] docs: render usable view selectors in compact routing policy --- internal/profiles/bodies.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 25149be7b..1fae0314c 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -73,7 +73,7 @@ var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work; §re const WorktreeBranchRoutingPolicy = "- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph.\n" + "- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy.\n" + "- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:\"relations\"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`.\n" + - "- Target another CWD: `view:{kind:\\\"worktree\\\",checkout_id:\\\"…\\\"}` or `view:{kind:\\\"git_ref\\\",value:\\\"refs/heads/release\\\",graph_id:\\\"…\\\"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not.\n" + + "- Target another CWD: `view:{kind:\"worktree\",checkout_id:\"…\"}` or `view:{kind:\"git_ref\",value:\"refs/heads/release\",graph_id:\"…\"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not.\n" + "- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation.\n" // sectionWorktreeViews teaches agents the ownership boundary that keeps From 9c846535849aab24fb39a016f65db09bf12007ec Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:16:02 +0100 Subject: [PATCH 040/138] fix: avoid failing receipts that finish during freshness polling --- internal/mcp/freshness_rider.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index faa010070..3359b246d 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -611,8 +611,12 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } } else { for _, receipt := range s.laggingMutationReceipts(ctx) { + outcome := receipt.outcome(true) + if !outcome.Pending && outcome.Err == nil && outcome.Reindexed { + continue // a receipt can finish between selection and this read + } state := "failed" - if receipt.outcome(true).Pending { + if outcome.Pending { state = "pending" } put(receipt.path, state, receipt.generation) From 99b02cdf2d593234f57b4f15534920017ce99177 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:18:37 +0100 Subject: [PATCH 041/138] test: guard semantic and mixed-failure relation publication boundaries --- .../indexer/mutation_tier_boundaries_test.go | 91 +++++++++++++++++++ internal/indexer/mutation_tiers_test.go | 2 +- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/indexer/mutation_tier_boundaries_test.go diff --git a/internal/indexer/mutation_tier_boundaries_test.go b/internal/indexer/mutation_tier_boundaries_test.go new file mode 100644 index 000000000..9272a347f --- /dev/null +++ b/internal/indexer/mutation_tier_boundaries_test.go @@ -0,0 +1,91 @@ +package indexer + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/semantic" +) + +func TestStandaloneRelationsWaitForSemanticCatchup(t *testing.T) { + t.Setenv("GORTEX_ENRICH_MIN_NODES", "0") + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + path := filepath.Join(dir, "main.go") + manager := semantic.NewManager(semantic.Config{Enabled: true, EnrichOnWatch: true}, zap.NewNop()) + manager.RegisterProvider(&retainingRepoProvider{retained: make(map[string]struct{}), leased: make(map[string]struct{})}) + idx.SetSemanticManager(manager) + writeTestFile(t, path, "package main\nfunc Changed() {}\n") + entered, release := make(chan MutationTierState, 1), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "semantic" { + entered <- watcher.TierState(path) + <-release + } + } + ticket, err := watcher.EnqueueToolMutation(t.Context(), path) + require.NoError(t, err) + select { + case state := <-entered: + require.True(t, state.RelationsAt.IsZero(), "semantic references have not published yet") + case <-time.After(2 * time.Second): + t.Fatal("the real semantic pipeline did not reach its hook") + } + unblock() + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + require.False(t, ticket.TierState().RelationsAt.IsZero()) + case <-time.After(2 * time.Second): + t.Fatal("semantic completion did not release the ticket") + } +} + +func TestMixedBatchDoesNotPublishFailedRelationsBeforeDerived(t *testing.T) { + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + good, bad := filepath.Join(dir, "main.go"), filepath.Join(dir, "bad.go") + writeTestFile(t, bad, "package main\nfunc Bad() {}\n") + require.NoError(t, idx.IndexFile(bad)) + idx.registry.Register(contractFailingGoExtractor{GoExtractor: languages.NewGoExtractor()}) + writeTestFile(t, good, "package main\nfunc GoodChanged() {}\n") + writeTestFile(t, bad, "package main\n// EXTRACT_FAIL\nfunc BadChanged() {}\n") + entered, release := make(chan MutationTierState, 1), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "derived" { + entered <- watcher.TierState(bad) + <-release + } + } + tickets, err := watcher.EnqueueFileMutations(t.Context(), []string{good, bad}) + require.NoError(t, err) + select { + case state := <-entered: + require.True(t, state.RelationsAt.IsZero(), "a failed parse cannot certify its old relations") + case <-time.After(2 * time.Second): + t.Fatal("mixed-success batch did not reach derived work") + } + unblock() + for path, ticket := range tickets { + select { + case result := <-ticket.Done: + if path == bad { + require.Error(t, result.Err) + } else { + require.NoError(t, result.Err) + } + case <-time.After(2 * time.Second): + t.Fatal("batch ticket did not finish") + } + } +} diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index 73ac1898d..b9f8a640d 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -36,7 +36,7 @@ func TestReceiptTierTimestampsAreOrdered(t *testing.T) { } func TestNativeOnlyStormHasFreshnessEvidence(t *testing.T) { - dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Before() {}\n") + dir, watcher := toolAdmissionFixture(t) path := filepath.Join(dir, "main.go") watcher.config.StormQuietPeriodMs = int(time.Hour / time.Millisecond) writeTestFile(t, path, "package main\nfunc After() {}\n") From 403209e5d4400d0471ff2a48c0fd4ed55db0eae1 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:21:19 +0100 Subject: [PATCH 042/138] test: keep deferred native publication tiers explicitly partial --- .../indexer/mutation_tier_boundaries_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/indexer/mutation_tier_boundaries_test.go b/internal/indexer/mutation_tier_boundaries_test.go index 9272a347f..a580be291 100644 --- a/internal/indexer/mutation_tier_boundaries_test.go +++ b/internal/indexer/mutation_tier_boundaries_test.go @@ -89,3 +89,21 @@ func TestMixedBatchDoesNotPublishFailedRelationsBeforeDerived(t *testing.T) { } } } + +func TestDeferredNativeBatchDoesNotCertifyUnrunTiers(t *testing.T) { + dir, watcher := toolAdmissionFixture(t) + idx := watcher.indexer + idx.SetDeferGlobalPasses(true) + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, "package main\nfunc DeferredChanged() {}\n") + watcher.config.StormQuietPeriodMs = int(time.Hour / time.Millisecond) + watcher.recordInStormEvent(path, ChangeModified, true) + frontier := watcher.mutationTierFrontier([]string{path}) + result, fanout, err := watcher.reindexMutationPaths([]string{path}, false, frontier) + require.NoError(t, err) + require.NotEmpty(t, idx.graph.FindNodesByName("DeferredChanged"), "file graph actually published") + watcher.completeStormMutationWaiters(frontier, result, nil, fanout) + state := watcher.TierState(path) + require.True(t, state.Partial, "deferred passes must remain visibly incomplete") + require.True(t, state.DerivedAt.IsZero(), "a skipped derived pass has no publication stamp") +} From e05428e2e884a1a5c4a25be9e8aeec6449fb06c4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:21:06 +0100 Subject: [PATCH 043/138] test: pin idle freshness reads and final response budgets --- .../freshness_idle_budget_contract_test.go | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 internal/mcp/freshness_idle_budget_contract_test.go diff --git a/internal/mcp/freshness_idle_budget_contract_test.go b/internal/mcp/freshness_idle_budget_contract_test.go new file mode 100644 index 000000000..e9f065e63 --- /dev/null +++ b/internal/mcp/freshness_idle_budget_contract_test.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +type freshnessIdleReadCounter struct { + graph.Reader + nodes, inEdges int +} + +func (r *freshnessIdleReadCounter) GetNode(id string) *graph.Node { + r.nodes++ + return r.Reader.GetNode(id) +} + +func (r *freshnessIdleReadCounter) GetInEdges(id string) []*graph.Edge { + r.inEdges++ + return r.Reader.GetInEdges(id) +} + +func TestFreshnessResponseContractIdleWatcherSkipsRelationReads(t *testing.T) { + s, _ := newRelationFixture(t) + id := targetID(t, s) + s.SetWatcher(&watchedTierFixture{}) + counter := &freshnessIdleReadCounter{Reader: s.graph} + // A narrowed base reader remains canonical: it must neither bypass the + // watcher nor build the seed's relation scope when no path is pending. + ctx := withRequestView(t.Context(), &requestView{reader: counter, baseNarrowed: true}) + require.Empty(t, s.freshnessFor(ctx, "relations", []string{id}, nil).state) + require.Equal(t, [2]int{0, 0}, [2]int{counter.nodes, counter.inEdges}, + "an idle watcher must add no seed lookup or in-edge scan to a read") +} + +func TestFreshnessResponseContractFinalPayloadHonorsByteBudget(t *testing.T) { + for _, name := range []string{"callers_compact", "file_summary_json", "facade_callers_compact"} { + t.Run(name, func(t *testing.T) { + s, root := newRelationFixture(t) + ctx := t.Context() + if name == "facade_callers_compact" { + const session = "freshness-budget-facade" + s.NoteSessionToolPolicy(session, FacadeSurfaceVersion, "hide") + ctx = WithSessionID(ctx, session) + } + seed := targetID(t, s) + // Many real graph rows let the existing structured and text budget + // policies remove rows to make room for the freshness label. + for i := 0; i < 40; i++ { + id := fmt.Sprintf("target.go::BudgetCaller%02d", i) + s.graph.AddNode(&graph.Node{ID: id, Name: fmt.Sprintf("BudgetCaller%02d", i), + Kind: graph.KindFunction, FilePath: "target.go", StartLine: i + 10, EndLine: i + 10, Language: "go"}) + s.graph.AddEdge(&graph.Edge{From: id, To: seed, Kind: graph.EdgeCalls, + FilePath: "target.go", Line: i + 10, Origin: graph.OriginASTResolved, Confidence: 1}) + } + call := func(maxBytes int) *mcp.CallToolResult { + t.Helper() + req := mcp.CallToolRequest{} + var handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) + switch name { + case "callers_compact": + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": seed, "compact": true, "max_bytes": maxBytes} + handler = s.handleGetCallers + case "file_summary_json": + req.Params.Name = "get_file_summary" + req.Params.Arguments = map[string]any{"path": "target.go", "format": "json", "max_bytes": maxBytes} + handler = s.handleGetFileSummary + case "facade_callers_compact": + req.Params.Name = "relations" + req.Params.Arguments = map[string]any{"operation": "callers", "target": map[string]any{"symbol": seed}, + "output": map[string]any{"compact": true, "max_bytes": maxBytes}} + handler = func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return s.handleFacade(ctx, "relations", req) + } + } + res, err := s.wrapToolHandler(handler)(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + return res + } + s.SetWatcher(&watchedTierFixture{}) + // Use the complete fresh answer's byte length as an explicit supported + // cap. The same rows fit before decoration; a pending answer must now + // keep its label and fit that cap by returning fewer rows if needed. + cap := len(resultText(call(0))) + require.Greater(t, cap, 600, "fixture must leave room for a useful labelled answer") + require.LessOrEqual(t, len(resultText(call(cap))), cap, "fresh control must honor max_bytes") + s.SetWatcher(&watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }}) + res := call(cap) + text := resultText(res) + if name == "file_summary_json" { + fields, ok := resultJSON(t, res)["freshness"].(map[string]any) + require.True(t, ok, "budget must preserve structured freshness") + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "file", fields["freshness_scope"]) + } else { + require.True(t, strings.HasPrefix(text, "relations: pending (1 files)\n"), + "budget must preserve the relations label: %s", text) + } + require.LessOrEqual(t, len(text), cap, "max_bytes bounds the final decorated response") + }) + } +} From c2d7c6a458bb6a171aaf75cb7225ac71340cbe1f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:23:49 +0100 Subject: [PATCH 044/138] indexer: certify relations after successful semantic and file catch-up --- internal/indexer/incremental_watcher_batch.go | 22 ++++++++++------ internal/indexer/multi.go | 2 +- internal/indexer/mutation_tiers.go | 25 ++++++++++++++++--- internal/indexer/watcher.go | 2 +- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index 5e36af3c4..1db47de1e 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -406,15 +406,15 @@ func (idx *Indexer) incrementalWatcherPaths(root string, paths []string, mode in idx.ResolveAll() resolve.end() } - if mode.onRelations != nil { - mode.onRelations() - } if mode.forceExplicitFiles || !idx.deferGlobalPasses.Load() { if mode.exactPointSemantic { idx.runIncrementalPointSemantic(result.DerivedInvalidation.Files) } else { idx.runIncrementalWatcherSemantic(result.DerivedInvalidation.Files) } + if mode.onRelations != nil && len(result.FailedFiles) == 0 { + mode.onRelations() + } idx.observeIncrementalCatchup("derived", result.DerivedInvalidation.Files) derived := mode.publication.beginDerivedPatchLap() idx.runStandaloneIncrementalDerivedPassesWithPrior(result.DerivedInvalidation, result.capabilityPrior) @@ -503,6 +503,9 @@ func (w *Watcher) reindexMutationPaths(paths []string, force bool, frontier map[ } observation := beginDerivedFanoutObservation(idx) defer observation.close() + if !force && idx.deferGlobalPasses.Load() { + w.markDeferredMutationTiers(frontier) + } mode := incrementalPathMode{detectDeletions: true, forceExplicitFiles: force, onRelations: func() { w.markRelationsFrontier(frontier) }} var err error @@ -521,18 +524,23 @@ func (w *Watcher) reindexMutationPaths(paths []string, force bool, frontier map[ } func (w *Watcher) reindexStormPaths(paths []string, admitted ...map[string]uint64) (*IndexResult, error) { + result, _, err := w.reindexStormPathsObserved(paths, admitted...) + return result, err +} + +func (w *Watcher) reindexStormPathsObserved(paths []string, admitted ...map[string]uint64) (*IndexResult, DerivedFanoutCompleteness, error) { if w.batchReindex != nil && (w.indexer == nil || w.indexer.repositoryMutationOwner == nil) { - return w.batchReindex(paths) + result, err := w.batchReindex(paths) + return result, DerivedFanoutCompleteness{}, err } if w.indexer == nil || w.indexer.rootPath == "" { - return nil, fmt.Errorf("watcher: index root is not initialized") + return nil, DerivedFanoutCompleteness{}, fmt.Errorf("watcher: index root is not initialized") } frontier := w.mutationTierFrontier(paths) if len(admitted) > 0 { frontier = admitted[0] } - result, _, err := w.reindexMutationPaths(paths, false, frontier) - return result, err + return w.reindexMutationPaths(paths, false, frontier) } func (gw *GitWatcher) reindexChangedPaths(paths []string) (*IndexResult, error) { diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index bfb70c8ee..15d68465d 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -2649,7 +2649,7 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ mi.mu.Unlock() mtimes.end() - if mode.onRelations != nil { + if mode.onRelations != nil && len(result.FailedFiles) == 0 { mode.onRelations() } idx.observeIncrementalCatchup("derived", result.DerivedInvalidation.Files) diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index a70558476..5efcee81a 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -81,9 +81,14 @@ func (w *Watcher) finishMutationTierLocked(path string, ticket *MutationTicket, return } at := time.Now() - stampRelations(state, at) // identity no-ops and test executors have no tail - state.DerivedAt = at - state.Partial = fanout.Observed && !fanout.Complete + deferred := state.Partial + if !deferred { + stampRelations(state, at) // identity no-ops and test executors have no tail + state.DerivedAt = at + } else if state.FileGraphAt.IsZero() { + state.FileGraphAt = at + } + state.Partial = deferred || fanout.Observed && !fanout.Complete if state.Partial { state.failedAt = at if w.failedPaths == nil { @@ -185,3 +190,17 @@ func (t *MutationTicket) TierState() MutationTierState { // CompleteStartupRelations is called only after successful warmup resolution. // ResetBatch also serves cancelled warmup and cannot certify this boundary. func (mi *MultiIndexer) CompleteStartupRelations() { mi.relationsPending.Store(false) } + +func (w *Watcher) markDeferredMutationTiers(frontier map[string]uint64) { + w.mu.Lock() + defer w.mu.Unlock() + for path, generation := range frontier { + for ordinal, waiter := range w.mutationWaiters[path] { + if ordinal <= generation && waiter.ticket != nil { + waiter.ticket.tierMu.Lock() + waiter.ticket.tiers.Partial = true + waiter.ticket.tierMu.Unlock() + } + } + } +} diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 529fe09fb..7972c6862 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -2659,7 +2659,7 @@ func (w *Watcher) drainStorm() { if explicit { result, fanout, err = w.reindexExplicitMutationPaths(paths, generations) } else { - result, err = w.reindexStormPaths(paths, generations) + result, fanout, err = w.reindexStormPathsObserved(paths, generations) } reindexed, deleted, failed := 0, 0, 0 From 69aa64d4574f340579d39f0039913ff35e324bbe Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:29:34 +0100 Subject: [PATCH 045/138] mcp: reserve freshness bytes before fitting response rows --- internal/mcp/facade_tools.go | 1 + internal/mcp/freshness_rider.go | 128 ++++++++++++++++++++++++++++++-- internal/mcp/overlay.go | 1 + 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 473f229ba..706d10ebb 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -1173,6 +1173,7 @@ func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest return refusal, nil } ctx = s.captureToolFreshness(ctx) + ctx, forwarded = s.reserveToolFreshnessBudget(ctx, spec.Legacy, forwarded) result, err = legacy.handler(ctx, forwarded) if err == nil { // Book the retrieval half of the savings ledger under the LEGACY tool diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 3359b246d..f12262bf8 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" "path/filepath" "slices" @@ -559,6 +560,26 @@ 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} + paths, watched := s.canonicalTierPaths(ctx) + var receipts []*mutationReceipt + if !watched { + receipts = s.laggingMutationReceipts(ctx) + } + startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() + var snapshot toolFreshnessSnapshot + if len(snapshots) > 0 { + snapshot, _ = ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot) + } + gapCount := len(paths) + len(receipts) + len(snapshot.paths) + if len(snapshots) > 0 { + gapCount += len(snapshots[0]) + } + if gapCount == 0 { + if scope != "file" && (startup || snapshot.startup) { + label.state = "pending" + } + return label // idle reads do not resolve seeds or enumerate incoming edges + } inScope := s.relationScope(ctx, ids, files) type pathState struct { state string @@ -605,12 +626,12 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st put(receipt.path, state, receipt.generation) } } - if paths, watched := s.canonicalTierPaths(ctx); watched { + if watched { for path, tiers := range paths { putTiers(path, tiers) } } else { - for _, receipt := range s.laggingMutationReceipts(ctx) { + for _, receipt := range receipts { outcome := receipt.outcome(true) if !outcome.Pending && outcome.Err == nil && outcome.Reindexed { continue // a receipt can finish between selection and this read @@ -629,7 +650,6 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } } slices.Sort(label.files) - startup := s.multiIndexer != nil && s.multiIndexer.RelationsPending() if len(snapshots) > 0 { if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { startup = startup || snapshot.startup @@ -731,12 +751,108 @@ func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp } } if rider := s.freshnessRiderFor(name, req); rider != nil { - return decorateResultWithFreshness(res, rider) + res = decorateResultWithFreshness(res, rider) } if isFreshnessListTool(name) { - return s.decorateListResultWithFreshness(res) + res = s.decorateListResultWithFreshness(res) + } + return fitToolFreshnessBudget(ctx, req, res) +} + +type toolFreshnessBudgetKey struct{} + +// Reserve the rider before the existing per-shape fitters choose rows. The +// original cap remains request-local so a late publication gap is fitted too. +func (s *Server) reserveToolFreshnessBudget(ctx context.Context, name string, req mcp.CallToolRequest) (context.Context, mcp.CallToolRequest) { + if _, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int); reserved { + return ctx, req + } + budget := effectiveBudget(req) + if budget <= 0 { + return ctx, req + } + scope, ids, files := freshnessTargets(name, req) + if scope == "" { + return ctx, req + } + if requested := takeRequestFreshness(&req).scope; requested != "" { + scope = requested + } + label := s.freshnessFor(ctx, scope, ids, files) + if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { + label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) + } + if label.state == "" { + return ctx, req + } + reserve := len(label.line()) + 1 + if !isCompact(req) { + fields := make(map[string]any) + label.apply(fields) + encoded, _ := json.Marshal(fields) + reserve += 2*len(encoded) + 64 // JSON block or escaped GCX header metadata + } + args := maps.Clone(req.GetArguments()) + if args == nil { + args = make(map[string]any) + } + args["max_bytes"] = max(1, budget-reserve) + req.Params.Arguments, req.Params.RawArguments = args, nil + return context.WithValue(ctx, toolFreshnessBudgetKey{}, budget), req +} + +func fitToolFreshnessBudget(ctx context.Context, req mcp.CallToolRequest, res *mcp.CallToolResult) *mcp.CallToolResult { + budget, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int) + if !reserved { + budget = effectiveBudget(req) + } + body, ok := singleTextContent(res) + if !ok || budget <= 0 || len(body) <= budget { + return res + } + var fields map[string]any + if json.Unmarshal([]byte(body), &fields) == nil && fields != nil { + rider, labelled := fields["freshness"].(map[string]any) + if !labelled { + return res + } + fitted, _ := applyBudget(fields, budget) + encoded, err := json.Marshal(fitted) + if err == nil && len(encoded) <= budget { + return rebuildTextResult(res, string(encoded)) + } + // An indivisible scalar can leave no answer rows to trim. Keep an + // explicit partial response, including the full pending-file count. + minimal := map[string]any{"freshness": maps.Clone(rider), budgetTruncatedKey: true} + minimalRider := minimal["freshness"].(map[string]any) + delete(minimalRider, "pending_files") + encoded, err = json.Marshal(minimal) + if err == nil && len(encoded) <= budget { + return rebuildTextResult(res, string(encoded)) + } + return mcp.NewToolResultError("freshness exceeds max_bytes") + } + line, _, _ := strings.Cut(body, "\n") + labelled := strings.HasPrefix(line, "file: ") || strings.HasPrefix(line, "relations: ") || strings.HasPrefix(line, "derived: ") + if strings.HasPrefix(body, wire.Tag) { + trimmed, _ := trimGCXBytes([]byte(body), budget) + if len(trimmed) <= budget { + return rebuildTextResult(res, string(trimmed)) + } + return mcp.NewToolResultError("freshness exceeds max_bytes") + } + if !labelled { + return res + } + if isCompact(req) || req.GetString("format", "") == "text" { + return rebuildTextResult(res, line+"\n"+trimTextToBudget(strings.TrimPrefix(body, line+"\n"), max(0, budget-len(line)-1))) + } + // TOON has no generic byte-cut operation. A newly admitted gap may have + // outgrown the reservation; return a labelled refusal rather than a torn table. + if len(line) <= budget { + return mcp.NewToolResultError(line) } - return res + return mcp.NewToolResultError("freshness exceeds max_bytes") } func (s *Server) awaitWatchedFreshness(ctx context.Context, name string, req mcp.CallToolRequest, freshness requestFreshness) *mcp.CallToolResult { diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index eb3fa0e4f..3af9766d7 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -358,6 +358,7 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay } indexer.StampPublicationPhase(ctx, indexer.PublicationHandlerStarted) ctx = s.captureToolFreshness(ctx) + ctx, req = s.reserveToolFreshnessBudget(ctx, req.Params.Name, req) res, hErr := s.invokeAcknowledgedLeaf(ctx, req, h) if hErr == nil && res != nil && !res.IsError && lateExactnessRefusalIsSafe(req.Params.Name) { if err := contractCoreReadError(ctx); err != nil { From e5ebe41217385883a7cd234f9fb6b5dd7e11305a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:31:16 +0100 Subject: [PATCH 046/138] fix(search): observe admitted tool writes before reindex --- internal/indexer/grep.go | 117 +++++++++++++- internal/indexer/grep_bounded.go | 2 + internal/indexer/grep_tool_write_test.go | 151 +++++++++++++++++++ internal/indexer/indexer.go | 4 + internal/indexer/watcher.go | 1 + internal/indexer/watcher_text_search_test.go | 66 ++++++++ 6 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 internal/indexer/grep_tool_write_test.go create mode 100644 internal/indexer/watcher_text_search_test.go diff --git a/internal/indexer/grep.go b/internal/indexer/grep.go index e70548eff..eeb05f152 100644 --- a/internal/indexer/grep.go +++ b/internal/indexer/grep.go @@ -2,9 +2,14 @@ package indexer import ( "context" + "errors" + "io/fs" + "os" + "path/filepath" "regexp" "regexp/syntax" "sort" + "sync" "github.com/zzet/gortex/internal/search/trigram" ) @@ -105,6 +110,7 @@ func (idx *Indexer) warmTrigramSearcher() *trigram.Searcher { var release func() var bytes int64 if searcher != nil { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } @@ -139,6 +145,10 @@ func (idx *Indexer) canPatchTrigramLocked() bool { // // Paths accumulate only while a searcher is live; with none built there is // nothing to patch and the next search builds from the current corpus. +// +// Tool-write notes outlive this call: the pass moves the generation only +// after it returns, and until then a live searcher learns of an edit from +// its note alone. With no searcher, a settled note has nothing left to do. func (idx *Indexer) noteTrigramDirty(paths ...string) { if idx == nil || len(paths) == 0 { return @@ -146,6 +156,7 @@ func (idx *Indexer) noteTrigramDirty(paths ...string) { idx.trigramMu.Lock() defer idx.trigramMu.Unlock() if idx.trigramSearcher == nil { + idx.toolWrites.retire(idx.toolWrites.snapshot(), idx.toolWriteSettled) return } if idx.trigramDirty == nil { @@ -158,6 +169,95 @@ func (idx *Indexer) noteTrigramDirty(paths ...string) { } } +// noteToolWrite records that a tool has just written a repo-relative path +// that no index pass has read yet. The next search reads the file's current +// bytes: a live searcher patches its postings first, and a streaming search +// walks the file even before the index tracks it. +func (idx *Indexer) noteToolWrite(rel string) { + if idx == nil || rel == "" { + return + } + idx.toolWrites.note(filepath.ToSlash(rel)) +} + +// applyToolWritesLocked reads every noted tool write into the live searcher, +// then retires the notes that have settled. The caller holds trigramMu and a +// live searcher. +func (idx *Indexer) applyToolWritesLocked() { + applied := idx.toolWrites.snapshot() + if len(applied) == 0 { + return + } + for rel := range applied { + idx.trigramSearcher.Update(rel) + } + idx.toolWrites.retire(applied, idx.toolWriteSettled) +} + +// toolWriteSettled reports whether a note on rel is no longer needed once the +// searcher holds its bytes, or once there is no searcher: a tracked file is +// covered by its reindex, which marks it dirty, and by every build, and a +// missing file has nothing left to find. A file no pass has tracked yet keeps +// its note, so knownFilePaths still offers it to streaming searches and +// rebuilds. +func (idx *Indexer) toolWriteSettled(rel string) bool { + idx.mtimeMu.RLock() + _, tracked := idx.fileMtimes[rel] + idx.mtimeMu.RUnlock() + if tracked { + return true + } + _, err := os.Stat(filepath.Join(idx.rootPath, filepath.FromSlash(rel))) + return errors.Is(err, fs.ErrNotExist) +} + +// toolWriteSet holds the paths tools wrote that the text index has not read +// yet, each with the sequence number of its newest note, so a note made +// while a search applied an older one is not retired with it. +type toolWriteSet struct { + mu sync.Mutex + seq uint64 + paths map[string]uint64 +} + +func (s *toolWriteSet) note(rel string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.paths == nil { + s.paths = make(map[string]uint64) + } + s.seq++ + s.paths[rel] = s.seq +} + +func (s *toolWriteSet) snapshot() map[string]uint64 { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.paths) == 0 { + return nil + } + out := make(map[string]uint64, len(s.paths)) + for rel, seq := range s.paths { + out[rel] = seq + } + return out +} + +// retire drops each applied note that has not been made again since and that +// done says is no longer needed. +func (s *toolWriteSet) retire(applied map[string]uint64, done func(rel string) bool) { + for rel, seq := range applied { + if !done(rel) { + continue + } + s.mu.Lock() + if s.paths[rel] == seq { + delete(s.paths, rel) + } + s.mu.Unlock() + } +} + // hasWarmTrigramSearcher reports whether this repo already holds a built // searcher, without building one. Callers that can stream instead use it // to keep a broad fan-out from building an index per repo. @@ -275,20 +375,27 @@ func (idx *Indexer) GrepRegexpPaths(pattern string, prefixes []string, limit int return matches, nil } -// knownFilePaths returns a sorted snapshot of the repo's indexed files — -// the corpus every streaming search walks, and the one Build indexes. +// knownFilePaths returns a sorted snapshot of the repo's indexed files plus +// the files tools wrote that no pass has tracked yet — the corpus every +// streaming search walks, and the one Build indexes. // -// Safe to call while holding trigramMu: mtimeMu is a leaf lock, and the -// build path took it in exactly this order before. +// Safe to call while holding trigramMu: mtimeMu and the tool-write lock are +// leaf locks, and the build path took mtimeMu in exactly this order before. func (idx *Indexer) knownFilePaths() []string { if idx == nil { return nil } + pending := idx.toolWrites.snapshot() idx.mtimeMu.RLock() - paths := make([]string, 0, len(idx.fileMtimes)) + paths := make([]string, 0, len(idx.fileMtimes)+len(pending)) for rel := range idx.fileMtimes { paths = append(paths, rel) } + for rel := range pending { + if _, tracked := idx.fileMtimes[rel]; !tracked { + paths = append(paths, rel) + } + } idx.mtimeMu.RUnlock() sort.Strings(paths) return paths diff --git a/internal/indexer/grep_bounded.go b/internal/indexer/grep_bounded.go index 5b570460f..f9c739313 100644 --- a/internal/indexer/grep_bounded.go +++ b/internal/indexer/grep_bounded.go @@ -27,6 +27,7 @@ func (idx *Indexer) GrepTextBounded( var release func() var bytes int64 if warm { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } @@ -62,6 +63,7 @@ func (idx *Indexer) GrepLiteralBounded( var release func() var bytes int64 if warm { + idx.applyToolWritesLocked() release = idx.trigramReleaseLocked() bytes = searcher.ApproxIndexBytes() } diff --git a/internal/indexer/grep_tool_write_test.go b/internal/indexer/grep_tool_write_test.go new file mode 100644 index 000000000..fc81207d4 --- /dev/null +++ b/internal/indexer/grep_tool_write_test.go @@ -0,0 +1,151 @@ +package indexer + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/search/trigram" +) + +// toolWriteTestIndexer indexes main.go and other.go with a trigram budget +// that builds on first demand, or never when maxLive is zero. +func toolWriteTestIndexer(t *testing.T, maxLive int) (string, *Indexer) { + t.Helper() + now := time.Unix(1_700_000_000, 0) + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Original() int { return 0 }\n") + writeTestFile(t, filepath.Join(dir, "other.go"), "package main\n\nfunc Other() {}\n") + return dir, &Indexer{ + rootPath: dir, + fileMtimes: map[string]int64{"main.go": 1, "other.go": 1}, + trigramBudgetOverride: newTestTrigramBudget(time.Hour, maxLive, &now), + } +} + +func requireOneMatch(t *testing.T, matches []trigram.Match, path string, line int) { + t.Helper() + require.Len(t, matches, 1) + require.Equal(t, path, matches[0].Path) + require.Equal(t, line, matches[0].Line) +} + +// TestTextSearchSeesToolWriteBeforeReindex covers a file a tool has written +// and no index pass has read yet: every text search answers from its new +// bytes, whether a live searcher serves it or a streaming scan does. +func TestTextSearchSeesToolWriteBeforeReindex(t *testing.T) { + ctx := context.Background() + + t.Run("a live searcher patches an edited file in every search kind", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + built, gen := idx.trigramSearcher, idx.indexGen.Load() + // No literal contains another, so each old line is gone, not shadowed. + searches := []struct { + literal string + search func(literal string) []trigram.Match + }{ + {"EditSeenByGrepText", func(literal string) []trigram.Match { return idx.GrepText(literal, 10) }}, + {"EditSeenByBoundedText", func(literal string) []trigram.Match { + m, _ := idx.GrepTextBounded(ctx, literal, 10, 0) + return m + }}, + {"EditSeenByBoundedLiteral", func(literal string) []trigram.Match { + m, _ := idx.GrepLiteralBounded(ctx, literal, 10, 0) + return m + }}, + {"EditSeenByRegexp", func(literal string) []trigram.Match { + m, err := idx.GrepRegexp(literal+`\(\)`, "", 10) + require.NoError(t, err) + return m + }}, + } + previous := "Original" + for _, tc := range searches { + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc "+tc.literal+"() int { return 0 }\n") + idx.noteToolWrite("main.go") + requireOneMatch(t, tc.search(tc.literal), "main.go", 3) + require.Empty(t, tc.search(previous), tc.literal+": the replaced line is gone") + require.Empty(t, idx.toolWrites.snapshot(), tc.literal+": a tracked file's note retires once applied") + previous = tc.literal + } + require.Same(t, built, idx.trigramSearcher, "a tool write patches, it never rebuilds") + require.Equal(t, gen, idx.indexGen.Load()) + }) + + t.Run("a new file is found before any pass tracks it, then handed to the pass", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + writeTestFile(t, filepath.Join(dir, "added.go"), "package main\n\nfunc AddedByTool() {}\n") + idx.noteToolWrite("added.go") + + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + // A path-scoped search always streams the known files. + requireOneMatch(t, idx.GrepTextPaths("AddedByTool", []string{"added.go"}, 10), "added.go", 3) + require.Contains(t, idx.toolWrites.snapshot(), "added.go", "an untracked file keeps its note") + + // The pass tracks the file: the next search retires its note. + idx.mtimeMu.Lock() + idx.fileMtimes["added.go"] = 2 + idx.mtimeMu.Unlock() + idx.noteTrigramDirty("added.go") + idx.indexGen.Add(1) + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + require.Empty(t, idx.toolWrites.snapshot()) + }) + + t.Run("an edit the pass has read is found before the pass moves the generation", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + require.NotNil(t, idx.warmTrigramSearcher()) + writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc EditedByTool() int { return 0 }\n") + idx.noteToolWrite("main.go") + // The pass has marked the file dirty, but the dirty set applies only + // once the generation moves, so this search has the note alone. + idx.noteTrigramDirty("main.go") + requireOneMatch(t, idx.GrepText("EditedByTool", 10), "main.go", 3) + require.Empty(t, idx.GrepText("Original", 10)) + idx.indexGen.Add(1) + requireOneMatch(t, idx.GrepText("EditedByTool", 10), "main.go", 3) + require.Empty(t, idx.toolWrites.snapshot()) + }) + + t.Run("a streaming search walks a new file", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 0) + writeTestFile(t, filepath.Join(dir, "added.go"), "package main\n\nfunc AddedByTool() {}\n") + idx.noteToolWrite("added.go") + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + require.Nil(t, idx.trigramSearcher, "the budget forbids a build") + + // With no searcher to apply it, the note retires once the pass tracks + // the file. + idx.mtimeMu.Lock() + idx.fileMtimes["added.go"] = 2 + idx.mtimeMu.Unlock() + idx.noteTrigramDirty("added.go") + idx.indexGen.Add(1) + require.Empty(t, idx.toolWrites.snapshot()) + requireOneMatch(t, idx.GrepText("AddedByTool", 10), "added.go", 3) + }) + + t.Run("a deleted file drops out", func(t *testing.T) { + dir, idx := toolWriteTestIndexer(t, 3) + requireOneMatch(t, idx.GrepText("func Other", 10), "other.go", 3) + require.NoError(t, os.Remove(filepath.Join(dir, "other.go"))) + idx.noteToolWrite("other.go") + require.Empty(t, idx.GrepText("func Other", 10)) + require.Empty(t, idx.toolWrites.snapshot(), "a missing file's note retires") + }) + + t.Run("a note made again while an older one is applied survives it", func(t *testing.T) { + var set toolWriteSet + set.note("main.go") + applied := set.snapshot() + set.note("main.go") + set.retire(applied, func(string) bool { return true }) + require.Contains(t, set.snapshot(), "main.go") + }) +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 035ab698c..28d12142f 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -324,6 +324,10 @@ type Indexer struct { // the whole corpus, which is what an index-generation bump used to // force after a single edit. trigramDirty map[string]struct{} + // toolWrites are the paths tools wrote that the text index has not read + // yet (grep.go). It has its own lock: noting a write must not wait out a + // trigram build, which holds trigramMu for a whole corpus read. + toolWrites toolWriteSet // trigramBudgetOverride scopes the idle/LRU eviction budget to one // test rather than the process-wide default. Nil in production. trigramBudgetOverride *trigramBudget diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 0b1d05da1..506e3b64d 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -1856,6 +1856,7 @@ func (w *Watcher) mutationAdmissionPath(ctx context.Context, filePath string) (s return "", errWatcherStopped default: } + w.indexer.noteToolWrite(rel) return absPath, nil } diff --git a/internal/indexer/watcher_text_search_test.go b/internal/indexer/watcher_text_search_test.go new file mode 100644 index 000000000..175cbce24 --- /dev/null +++ b/internal/indexer/watcher_text_search_test.go @@ -0,0 +1,66 @@ +package indexer + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// All committed-mutation front doors must expose the written bytes while the +// graph patch is deliberately held back; admission does not wait on reindex. +func TestWatcherAdmittedEditVisibleToTextSearch(t *testing.T) { + for _, admission := range []string{"debounced", "tool", "set"} { + t.Run(admission, func(t *testing.T) { + dir, idx, w := inertTestWatcher(t, "main.go", "package main\n\nfunc value() int { return 0 }\n") + now := time.Unix(1_700_000_000, 0) + idx.trigramBudgetOverride = newTestTrigramBudget(time.Hour, 3, &now) + require.NotEmpty(t, idx.GrepText("func value", 10)) + require.NotNil(t, idx.trigramSearcher, "the searcher is warm") + + path := filepath.Join(dir, "main.go") + w.patchMu.Lock() + var unlocked sync.Once + unlock := func() { unlocked.Do(w.patchMu.Unlock) } + defer unlock() + writeTestFile(t, path, "package main\n\nfunc value() int { return admittedValue }\n\nconst admittedValue = 1\n") + var tickets []*MutationTicket + switch admission { + case "debounced", "tool": + admit := w.EnqueueFileMutation + if admission == "tool" { + admit = w.EnqueueToolMutation + } + ticket, err := admit(context.Background(), path) + require.NoError(t, err) + require.NotNil(t, ticket) + tickets = append(tickets, ticket) + case "set": + side := filepath.Join(dir, "side.go") + writeTestFile(t, side, "package main\nconst sideValue = 1\n") + admitted, err := w.EnqueueFileMutations(context.Background(), []string{path, side}) + require.NoError(t, err) + require.Len(t, admitted, 2) + for _, ticket := range admitted { + tickets = append(tickets, ticket) + } + require.Len(t, idx.GrepText("sideValue", 10), 1, "a newly written untracked member must be searchable before the set drains") + } + matches := idx.GrepText("admittedValue", 10) + unlock() + for _, ticket := range tickets { + select { + case <-ticket.Done: + case <-time.After(5 * time.Second): + t.Fatal("the admitted edit was never reindexed") + } + } + require.Len(t, matches, 2) + require.Equal(t, 3, matches[0].Line) + require.Equal(t, 5, matches[1].Line) + }) + } +} From 0e9bedbca59d08c259352e4cf643fe97b668ef4e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:34:04 +0100 Subject: [PATCH 047/138] bench: retain independent snapshots of structured RPC errors --- bench/load/rpc_error_observation.go | 29 ++++++++++++++++++++++++ bench/load/rpc_error_observation_test.go | 19 ++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 bench/load/rpc_error_observation.go create mode 100644 bench/load/rpc_error_observation_test.go diff --git a/bench/load/rpc_error_observation.go b/bench/load/rpc_error_observation.go new file mode 100644 index 000000000..52bc08e22 --- /dev/null +++ b/bench/load/rpc_error_observation.go @@ -0,0 +1,29 @@ +package main + +// Error data is decoded JSON. Keep a private snapshot when an observation +// outlives its RPC, including nested objects and lists of pending paths. +func copyRPCErrorData(data map[string]any) map[string]any { + if data == nil { + return nil + } + out := make(map[string]any, len(data)) + for key, value := range data { + out[key] = copyRPCErrorValue(value) + } + return out +} + +func copyRPCErrorValue(value any) any { + switch value := value.(type) { + case map[string]any: + return copyRPCErrorData(value) + case []any: + out := make([]any, len(value)) + for i, item := range value { + out[i] = copyRPCErrorValue(item) + } + return out + default: + return value + } +} diff --git a/bench/load/rpc_error_observation_test.go b/bench/load/rpc_error_observation_test.go new file mode 100644 index 000000000..8a40e8c11 --- /dev/null +++ b/bench/load/rpc_error_observation_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestRPCErrorDataSnapshotOwnsNestedJSON(t *testing.T) { + original := map[string]any{"code": "view_pending", "details": map[string]any{"paths": []any{"one.go", map[string]any{"scope": "checkout"}}}} + snapshot := copyRPCErrorData(original) + original["code"] = "changed" + details := original["details"].(map[string]any) + details["paths"].([]any)[0] = "two.go" + details["paths"].([]any)[1].(map[string]any)["scope"] = "other" + kept := snapshot["details"].(map[string]any)["paths"].([]any) + if snapshot["code"] != "view_pending" || kept[0] != "one.go" || kept[1].(map[string]any)["scope"] != "checkout" { + t.Fatalf("snapshot borrowed decoded RPC data: %+v", snapshot) + } + if copyRPCErrorData(nil) != nil { + t.Fatal("nil data became a fabricated object") + } +} From 26dfe46f77fb23e2b2f16828b315e790d204202c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:34:04 +0100 Subject: [PATCH 048/138] reach: observe lookup gate and resolver mutex admission separately --- internal/reach/coordination_wait.go | 42 +++++++++++ internal/reach/coordination_wait_test.go | 95 ++++++++++++++++++++++++ internal/reach/reach.go | 16 +++- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 internal/reach/coordination_wait.go create mode 100644 internal/reach/coordination_wait_test.go diff --git a/internal/reach/coordination_wait.go b/internal/reach/coordination_wait.go new file mode 100644 index 000000000..ef81ed0c1 --- /dev/null +++ b/internal/reach/coordination_wait.go @@ -0,0 +1,42 @@ +package reach + +import ( + "context" + "time" +) + +// CoordinationWait describes one actual lookup admission, separately from +// traversal time. Detached readers report Attempted=false for the mutex. +type CoordinationWait struct { + Phase string + Duration time.Duration + Attempted bool + Acquired bool +} + +type coordinationWaitKey struct{} + +// WithCoordinationWaitObserver observes only lookups descended from ctx; it +// retains no process-wide history and changes no gate or locking policy. +func WithCoordinationWaitObserver(ctx context.Context, observer func(CoordinationWait)) context.Context { + if observer == nil { + return ctx + } + return context.WithValue(ctx, coordinationWaitKey{}, observer) +} + +func coordinationWaitObserver(ctx context.Context) func(CoordinationWait) { + observer, _ := ctx.Value(coordinationWaitKey{}).(func(CoordinationWait)) + return observer +} + +func observedTopologyRead(ctx context.Context, scope []string) (func(), bool) { + observer := coordinationWaitObserver(ctx) + if observer == nil { + return beginTopologyRead(ctx, scope) + } + started := time.Now() + release, ok := beginTopologyRead(ctx, scope) + observer(CoordinationWait{Phase: "gate_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) + return release, ok +} diff --git a/internal/reach/coordination_wait_test.go b/internal/reach/coordination_wait_test.go new file mode 100644 index 000000000..61a91e08c --- /dev/null +++ b/internal/reach/coordination_wait_test.go @@ -0,0 +1,95 @@ +package reach + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestLookupCoordinationWaitKeepsGateAndMutexSeparate(t *testing.T) { + for _, held := range []string{"gate_wait", "resolve_mutex_wait"} { + t.Run(held, func(t *testing.T) { + g, ids := newCallChain(t, 3) + var release func() + if held == "gate_wait" { + finish := BeginTopologyMutation(g) + release = func() { finish(false) } + } else { + mu := g.ResolveMutex() + mu.Lock() + release = mu.Unlock + } + var once sync.Once + unblock := func() { once.Do(release) } + t.Cleanup(unblock) + observed := make(chan CoordinationWait, 8) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { observed <- wait }) + done := make(chan struct{}) + go func() { LookupContext(ctx, g, ids[2]); close(done) }() + timer := time.NewTimer(20 * time.Millisecond) + defer timer.Stop() + var events []CoordinationWait + waiting := true + for waiting { + select { + case wait := <-observed: + events = append(events, wait) + if wait.Phase == held { + t.Fatalf("%s reported acquired before its held lock released: %+v", held, wait) + } + case <-timer.C: + waiting = false + } + } + unblock() + select { + case <-done: + case <-ctx.Done(): + t.Fatal("lookup did not complete after its lock released") + } + for len(observed) > 0 { + events = append(events, <-observed) + } + found := false + for _, wait := range events { + if wait.Phase == held { + found = true + if !wait.Attempted || !wait.Acquired || wait.Duration <= 0 { + t.Fatalf("held wait was not actually measured: %+v", wait) + } + } + } + if !found { + t.Fatalf("missing %s observation: %+v", held, events) + } + }) + } +} + +func TestLookupCoordinationWaitReportsDetachedMutexAsNotAttempted(t *testing.T) { + store := repoTopologyStore(t) + mu := store.ResolveMutex() + mu.Lock() + defer mu.Unlock() + var events []CoordinationWait + ctx := WithCoordinationWaitObserver(t.Context(), func(wait CoordinationWait) { events = append(events, wait) }) + _, _, _, hit, truncated := LookupContext(ctx, store, seedA) + if !hit || truncated { + t.Fatalf("detached lookup hit=%v truncated=%v", hit, truncated) + } + found := false + for _, wait := range events { + if wait.Phase == "resolve_mutex_wait" { + found = true + if wait.Attempted || !wait.Acquired { + t.Fatalf("detached read claimed to acquire the held mutex: %+v", wait) + } + } + } + if !found { + t.Fatal("detached mutex disposition was absent") + } +} diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 179790d67..8d28000b1 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -360,11 +360,23 @@ type detachedNodeReader interface { // from it. A record write always takes the mutex: it rewrites whole node rows // that a pass holding the mutex may be rewriting too. func lockResolve(ctx context.Context, g graph.Store) (unlock func(), ok bool) { + observer := coordinationWaitObserver(ctx) if detached, isDetached := g.(detachedNodeReader); isDetached && detached.NodeReadsDetached() { + if observer != nil { + observer(CoordinationWait{Phase: "resolve_mutex_wait", Acquired: true}) + } return func() {}, true } + var started time.Time + if observer != nil { + started = time.Now() + } mu := g.ResolveMutex() - if !lockContext(ctx, mu) { + ok = lockContext(ctx, mu) + if observer != nil { + observer(CoordinationWait{Phase: "resolve_mutex_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) + } + if !ok { return nil, false } return mu.Unlock, true @@ -1224,7 +1236,7 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed scope := seedScope(ctx, g, seedID) for retried := false; ; retried = true { - releaseTopology, ok := beginTopologyRead(ctx, scope) + releaseTopology, ok := observedTopologyRead(ctx, scope) if !ok { return nil, nil, nil, false, true } From 9dc0d59b7423831a89faf09ee439717589d0e703 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:34:04 +0100 Subject: [PATCH 049/138] mcp: carry actual reach admission waits in impact response metadata --- internal/mcp/impact_waits.go | 73 +++++++++++++++++++++++++++++++ internal/mcp/impact_waits_test.go | 60 +++++++++++++++++++++++++ internal/mcp/tools_analysis.go | 4 +- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 internal/mcp/impact_waits.go create mode 100644 internal/mcp/impact_waits_test.go diff --git a/internal/mcp/impact_waits.go b/internal/mcp/impact_waits.go new file mode 100644 index 000000000..38e5f08e0 --- /dev/null +++ b/internal/mcp/impact_waits.go @@ -0,0 +1,73 @@ +package mcp + +import ( + "context" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/reach" +) + +type impactWaitPhase struct { + observed, attempted, acquired, detached int + total time.Duration +} + +type impactCoordinationWaits struct { + mu sync.Mutex + gate, resolve impactWaitPhase +} + +func withImpactCoordinationWaits(ctx context.Context) (context.Context, *impactCoordinationWaits) { + waits := new(impactCoordinationWaits) + return reach.WithCoordinationWaitObserver(ctx, waits.observe), waits +} + +func (w *impactCoordinationWaits) observe(wait reach.CoordinationWait) { + w.mu.Lock() + defer w.mu.Unlock() + var phase *impactWaitPhase + switch wait.Phase { + case "gate_wait": + phase = &w.gate + case "resolve_mutex_wait": + phase = &w.resolve + default: + return + } + phase.observed++ + if !wait.Attempted { + phase.detached++ + return + } + phase.attempted++ + phase.total += wait.Duration + if wait.Acquired { + phase.acquired++ + } +} + +func impactWaitFields(phase impactWaitPhase) map[string]any { + state := "not_observed" + var ms any + switch { + case phase.attempted > phase.acquired: + state = "censored" + ms = float64(phase.total) / float64(time.Millisecond) + case phase.attempted > 0: + state = "observed" + ms = float64(phase.total) / float64(time.Millisecond) + case phase.detached > 0: + state = "not_applicable_detached" + } + return map[string]any{"state": state, "total_ms": ms, "observations": phase.observed, "attempted": phase.attempted, "acquired": phase.acquired, "detached": phase.detached} +} + +// Timing travels in the existing format-independent metadata envelope. The +// selected JSON/TOON/GCX payload is unchanged; no status registry is retained. +func attachImpactCoordinationWaits(result *mcp.CallToolResult, w *impactCoordinationWaits) *mcp.CallToolResult { + w.mu.Lock() + defer w.mu.Unlock() + return mergeResultMeta(result, map[string]any{"reach_coordination": map[string]any{"gate_wait": impactWaitFields(w.gate), "resolve_mutex_wait": impactWaitFields(w.resolve)}}) +} diff --git a/internal/mcp/impact_waits_test.go b/internal/mcp/impact_waits_test.go new file mode 100644 index 000000000..922870733 --- /dev/null +++ b/internal/mcp/impact_waits_test.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/reach" +) + +func TestImpactCoordinationMetadataPreservesPayloadAndMissingStates(t *testing.T) { + waits := new(impactCoordinationWaits) + waits.observe(reach.CoordinationWait{Phase: "gate_wait", Duration: 12 * time.Millisecond, Attempted: true, Acquired: true}) + waits.observe(reach.CoordinationWait{Phase: "resolve_mutex_wait", Acquired: true}) + original := `{"complete":true,"by_depth":{}}` + result := attachImpactCoordinationWaits(mcp.NewToolResultText(original), waits) + if got := result.Content[0].(mcp.TextContent).Text; got != original { + t.Fatalf("wait metadata changed semantic payload: %s", got) + } + meta := result.Meta.AdditionalFields["reach_coordination"].(map[string]any) + gate := meta["gate_wait"].(map[string]any) + resolve := meta["resolve_mutex_wait"].(map[string]any) + if gate["state"] != "observed" || gate["total_ms"] != float64(12) { + t.Fatalf("gate wait lost: %v", gate) + } + if resolve["state"] != "not_applicable_detached" || resolve["total_ms"] != nil { + t.Fatalf("detached mutex became a measured zero: %v", resolve) + } + missing := impactWaitFields(impactWaitPhase{}) + if missing["state"] != "not_observed" || missing["total_ms"] != nil { + t.Fatalf("unobserved wait became zero: %v", missing) + } + interrupted := impactWaitFields(impactWaitPhase{observed: 1, attempted: 1, total: time.Second}) + if interrupted["state"] != "censored" { + t.Fatalf("interrupted wait claimed complete: %v", interrupted) + } +} + +func TestImpactCoordinationHandlerForwardsRealLookupWaits(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "repo/a.go", RepoPrefix: "repo"}) + s := &Server{graph: g, engine: query.NewEngine(g), session: newSessionState()} + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "explain_change_impact", Arguments: map[string]any{"ids": "repo/a.go::A", "format": "json"}}} + result, err := s.handleEnhancedChangeImpact(t.Context(), req) + if err != nil || result == nil || result.IsError { + t.Fatalf("impact failed: %v %+v", err, result) + } + if result.Meta == nil { + t.Fatal("handler dropped actual wait observations") + } + fields, _ := result.Meta.AdditionalFields["reach_coordination"].(map[string]any) + for _, name := range []string{"gate_wait", "resolve_mutex_wait"} { + phase, _ := fields[name].(map[string]any) + if phase["state"] != "observed" || phase["attempted"] != 1 || phase["acquired"] != 1 { + t.Fatalf("%s did not reflect actual lookup: %v", name, phase) + } + } +} diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index feae760a1..48a386bac 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -476,7 +476,9 @@ func (s *Server) tryImpactAnalysisSnapshots() (*analysis.CommunityResult, *analy // handleEnhancedChangeImpact replaces the original explain_change_impact with risk tiering // and cross-community warnings. -func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToolRequest) (out *mcp.CallToolResult, outErr error) { + ctx, waits := withImpactCoordinationWaits(ctx) + defer func() { out = attachImpactCoordinationWaits(out, waits) }() idsStr, err := req.RequireString("ids") if err != nil { return mcp.NewToolResultError("ids is required"), nil From 019c7ec72fef2a2e6be071cb98ccb7df26edf99f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:34:49 +0100 Subject: [PATCH 050/138] feat(bench): declare dedicated edit-loop absolute budget rows --- bench/budgets.yaml | 13 +++++++ bench/check-budgets/edit_loop_rows_test.go | 44 ++++++++++++++++++++++ bench/check-budgets/policy.go | 2 +- bench/check-budgets/specs.go | 7 ++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 bench/check-budgets/edit_loop_rows_test.go diff --git a/bench/budgets.yaml b/bench/budgets.yaml index 9ea8b83aa..4decda144 100644 --- a/bench/budgets.yaml +++ b/bench/budgets.yaml @@ -894,3 +894,16 @@ budgets: - {"metric": "sigaborts", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} - {"metric": "oom_kills", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} - {"metric": "force_kills", "scenario": "warm-restart", "host_class": "hosted_arm_smoke", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["smoke"], "compare": "eq"} + + # Dedicated edit-loop evidence; the frozen report-v1 lane cannot satisfy these. + - {"metric": "silent_stale_json", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1007, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1059, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 960, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_p95_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 760, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.6, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} diff --git a/bench/check-budgets/edit_loop_rows_test.go b/bench/check-budgets/edit_loop_rows_test.go new file mode 100644 index 000000000..4e383935f --- /dev/null +++ b/bench/check-budgets/edit_loop_rows_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "slices" + "testing" +) + +func TestBudgetsDeclareEveryEditLoopRow(t *testing.T) { + data, err := os.ReadFile("../budgets.yaml") + if err != nil { + t.Fatal(err) + } + p, err := loadPolicy(data) + if err != nil { + t.Fatal(err) + } + requireRow := func(scenario, metric string, target float64, unit, compare string) { + t.Helper() + for _, row := range p.Budgets { + if row.Scenario != scenario || row.Metric != metric || row.HostClass != "reference_linux" || !slices.Contains(row.Lane, "edit-smoke") { + continue + } + if row.Mode != "block" || row.OwningRelease != "v0.64.12" || row.Target == nil || *row.Target != target || row.Unit != unit || row.Compare != compare { + t.Errorf("incorrect edit-loop row: %+v", row) + } + return + } + t.Errorf("missing absolute edit-smoke row %s/%s", scenario, metric) + } + for _, mode := range []string{"json", "toon", "compact", "facade", "cli"} { + requireRow("loadedit-4", "silent_stale_"+mode, 0, "count", "eq") + } + for _, cell := range []struct { + scenario string + walP95 float64 + }{ + {"loadedit-1", 1007}, {"loadedit-4", 1059}, {"loadedit-8", 960}, {"multirepoedit-6", 760}, + } { + requireRow(cell.scenario, "wal_p95_mib", cell.walP95, "MiB", "le") + } + requireRow("loadedit-4-largefile", "edit_ack_p95", 0.6, "s", "le") + requireRow("loadedit-4-largefile", "edit_ack_max", 1, "s", "le") +} diff --git a/bench/check-budgets/policy.go b/bench/check-budgets/policy.go index 1a0ba68f3..21f0e252c 100644 --- a/bench/check-budgets/policy.go +++ b/bench/check-budgets/policy.go @@ -181,7 +181,7 @@ func validateBudget(b budget, allowances map[string]allowance) error { return errors.New("min_repeats cannot weaken the three-repeat minimum") } for i, lane := range b.Lane { - if !slices.Contains([]string{"smoke", "nightly", "release"}, lane) || slices.Contains(b.Lane[:i], lane) { + if !slices.Contains([]string{"smoke", "nightly", "release", "edit-smoke"}, lane) || slices.Contains(b.Lane[:i], lane) { return fmt.Errorf("unknown or repeated lane %s", lane) } } diff --git a/bench/check-budgets/specs.go b/bench/check-budgets/specs.go index a2dcfbf5d..4bb3225b9 100644 --- a/bench/check-budgets/specs.go +++ b/bench/check-budgets/specs.go @@ -11,6 +11,7 @@ type metricSpec struct { } var metricSpecs = map[string]metricSpec{ + "wal_p95_mib": {unit: "MiB", axis: "disk", missing: "report-v1 does not retain a WAL observation distribution"}, "edit_ack_p95": {unit: "s", axis: "timing"}, "edit_ack_max": {unit: "s", axis: "timing", everyRepeat: true}, "read_p95": {unit: "s", axis: "timing"}, @@ -49,6 +50,11 @@ var metricSpecs = map[string]metricSpec{ "idle_written_mib_min": {unit: "MiB/min", axis: "disk", missing: "report-v1 lacks an idle-write observation interval"}, "idle_memory_60s_mib": {unit: "MiB", axis: "memory", missing: "idle_after_2s is not a 60-second post-enrichment footprint observation"}, "cold_go_sys_peak_mib": {unit: "MiB", axis: "memory", missing: "daemon status Go Sys snapshots do not establish cold-window peak Go Sys"}, + "silent_stale_json": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_toon": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_compact": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_facade": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, + "silent_stale_cli": {unit: "count", axis: "safety", everyRepeat: true, missing: "report-v1 does not retain mode-specific edit oracle observations"}, } type scenarioSpec struct { @@ -71,6 +77,7 @@ func buildScenarioSpecs() map[string]scenarioSpec { out["load-"+cell.name] = scenarioSpec{wire: "read", agents: cell.n, repos: 1} out["loadedit-"+cell.name] = scenarioSpec{wire: "edit", editors: cell.n, repos: 1} } + out["loadedit-4-largefile"] = scenarioSpec{missing: "frozen report-v1 has no identified large-file edit-probe fixture"} out["multirepoedit-6"] = scenarioSpec{wire: "many-edit", editors: 6, repos: 6} for _, cell := range []struct { name string From 9c513fda9bf338088a946dedfeaf01409d0ff355 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:37:39 +0100 Subject: [PATCH 051/138] mcp: retain the freshness cap after shared response riders --- internal/mcp/overlay.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 3af9766d7..49f83cf4c 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -466,6 +466,9 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // (momentum.go). No-op for non-read tools and error results. if hErr == nil { res = s.maybeAttachMomentumNote(ctx, req.Params.Name, res) + if _, reserved := ctx.Value(toolFreshnessBudgetKey{}).(int); reserved { + res = fitToolFreshnessBudget(ctx, req, res) + } } return res, hErr })) From 33ba9cdbfe5c4afe36ebe74680eccaa4b0e26ca4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:39:57 +0100 Subject: [PATCH 052/138] test: refresh installed agent guidance fixtures for publication tiers --- .../testdata/agent-render/antigravity.txt | 20 +- .../testdata/agent-render/claude-code.txt | 470 +++++++++--------- cmd/gortex/testdata/agent-render/codex.txt | 240 ++++----- .../testdata/agent-render/copilot-cli.txt | 240 ++++----- cmd/gortex/testdata/agent-render/cursor.txt | 10 +- cmd/gortex/testdata/agent-render/hermes.txt | 400 +++++++-------- cmd/gortex/testdata/agent-render/opencode.txt | 410 +++++++-------- 7 files changed, 895 insertions(+), 895 deletions(-) diff --git a/cmd/gortex/testdata/agent-render/antigravity.txt b/cmd/gortex/testdata/agent-render/antigravity.txt index 84fe0ba28..30ce49db9 100644 --- a/cmd/gortex/testdata/agent-render/antigravity.txt +++ b/cmd/gortex/testdata/agent-render/antigravity.txt @@ -24,11 +24,11 @@ Common calls: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. @@ -120,11 +120,11 @@ Common calls: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. diff --git a/cmd/gortex/testdata/agent-render/claude-code.txt b/cmd/gortex/testdata/agent-render/claude-code.txt index 1c4c891d1..6676e56be 100644 --- a/cmd/gortex/testdata/agent-render/claude-code.txt +++ b/cmd/gortex/testdata/agent-render/claude-code.txt @@ -185,11 +185,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/agents/gortex-search.md === --- name: gortex-search @@ -210,11 +210,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-add-test.md === # Add Tests with Gortex @@ -235,11 +235,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-architecture-review.md === # Review Architecture with Gortex @@ -259,11 +259,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-co-change.md === # Analyze Co-Change with Gortex @@ -283,11 +283,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-cross-repo-usage.md === # Find Cross-Repository Usage with Gortex @@ -308,11 +308,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-dataflow-trace.md === # Trace Data Flow with Gortex @@ -333,11 +333,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-debug.md === # Debug with Gortex @@ -358,11 +358,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-episode-replay.md === # Replay a Change Episode with Gortex @@ -383,11 +383,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-explore.md === # Explore a Codebase with Gortex @@ -407,11 +407,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-extract-function.md === # Extract a Function with Gortex @@ -432,11 +432,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-fix-all.md === # Fix Diagnostics with Gortex @@ -456,11 +456,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-guide.md === # Gortex Guide @@ -484,11 +484,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-impact.md === # Assess Change Impact with Gortex @@ -509,11 +509,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-incident-investigation.md === # Investigate an Incident with Gortex @@ -534,11 +534,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-onboarding.md === # Onboard to a Repository with Gortex @@ -558,11 +558,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-pr-review-agent.md === # Review a Change as a Sub-Agent @@ -582,11 +582,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-pr-review.md === # Review a Change with Gortex @@ -607,11 +607,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-quality-audit.md === # Audit Repository Quality with Gortex @@ -631,11 +631,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-refactor.md === # Refactor with Gortex @@ -655,11 +655,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-rename.md === # Rename a Symbol with Gortex @@ -680,11 +680,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/commands/gortex-safe-edit.md === # Make a Safe Edit with Gortex @@ -704,11 +704,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/settings.json === { "hooks": { @@ -839,11 +839,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -867,11 +867,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -903,11 +903,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -931,11 +931,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -960,11 +960,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -989,11 +989,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -1018,11 +1018,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -1047,11 +1047,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1075,11 +1075,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1104,11 +1104,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1132,11 +1132,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -1164,11 +1164,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -1193,11 +1193,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1222,11 +1222,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1250,11 +1250,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -1278,11 +1278,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1307,11 +1307,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1335,11 +1335,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1363,11 +1363,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1392,11 +1392,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.claude/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1420,11 +1420,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.gortex/instructions/active.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -1445,11 +1445,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1478,11 +1478,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1536,11 +1536,11 @@ The graph remembers code; these tools remember **why you made a call**. They are ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -1569,11 +1569,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. **Reference:** `gortex://guide` for deeper background. diff --git a/cmd/gortex/testdata/agent-render/codex.txt b/cmd/gortex/testdata/agent-render/codex.txt index 6b1765ee0..50eb4c865 100644 --- a/cmd/gortex/testdata/agent-render/codex.txt +++ b/cmd/gortex/testdata/agent-render/codex.txt @@ -98,11 +98,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -127,11 +127,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -164,11 +164,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -193,11 +193,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -223,11 +223,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -253,11 +253,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -283,11 +283,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -313,11 +313,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -342,11 +342,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -372,11 +372,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -401,11 +401,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -434,11 +434,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -464,11 +464,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -494,11 +494,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -523,11 +523,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -552,11 +552,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -582,11 +582,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -611,11 +611,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -640,11 +640,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -670,11 +670,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.agents/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -699,11 +699,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.codex/AGENTS.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -725,11 +725,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -757,11 +757,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. ''' === global/home/.codex/agents/gortex-search.toml === name = "gortex-search" @@ -781,11 +781,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. ''' === global/home/.codex/config.toml === [features] diff --git a/cmd/gortex/testdata/agent-render/copilot-cli.txt b/cmd/gortex/testdata/agent-render/copilot-cli.txt index 371769cbd..0b3f38e2d 100644 --- a/cmd/gortex/testdata/agent-render/copilot-cli.txt +++ b/cmd/gortex/testdata/agent-render/copilot-cli.txt @@ -51,11 +51,11 @@ Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guar ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/agents/gortex-search.agent.md === --- name: gortex-search @@ -75,11 +75,11 @@ Return the answer first, then symbol IDs and file:line evidence, then caveats. D ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/copilot-instructions.md === ## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob @@ -101,11 +101,11 @@ Use `recall` before revisiting prior work; `remember` immediately for a durable ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. Never restart the daemon, delete its store, or re-track merely to expose a worktree. ## Reference and discovery @@ -197,11 +197,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -225,11 +225,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -261,11 +261,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -289,11 +289,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -318,11 +318,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -347,11 +347,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -376,11 +376,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -405,11 +405,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -433,11 +433,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -462,11 +462,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -490,11 +490,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -522,11 +522,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -551,11 +551,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -580,11 +580,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -608,11 +608,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -636,11 +636,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -665,11 +665,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -693,11 +693,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -721,11 +721,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -750,11 +750,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.copilot/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -778,8 +778,8 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. diff --git a/cmd/gortex/testdata/agent-render/cursor.txt b/cmd/gortex/testdata/agent-render/cursor.txt index d22c19703..e441ca454 100644 --- a/cmd/gortex/testdata/agent-render/cursor.txt +++ b/cmd/gortex/testdata/agent-render/cursor.txt @@ -41,11 +41,11 @@ Use **recall** before editing known code and **remember** for durable decisions ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.cursor/mcp.json === { "mcpServers": { diff --git a/cmd/gortex/testdata/agent-render/hermes.txt b/cmd/gortex/testdata/agent-render/hermes.txt index 48f2c0585..f412de5e1 100644 --- a/cmd/gortex/testdata/agent-render/hermes.txt +++ b/cmd/gortex/testdata/agent-render/hermes.txt @@ -45,11 +45,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -80,11 +80,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -116,11 +116,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-impact/SKILL.md === --- name: gortex-impact @@ -152,11 +152,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -187,11 +187,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -223,11 +223,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/analysis/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -258,11 +258,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/code-intelligence/gortex-cli/SKILL.md === --- name: gortex-cli @@ -301,11 +301,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/code-intelligence/gortex/SKILL.md === --- name: gortex @@ -389,11 +389,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/debugging/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -425,11 +425,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -461,11 +461,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -497,11 +497,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-explore/SKILL.md === --- name: gortex-explore @@ -532,11 +532,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/navigation/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -567,11 +567,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -603,11 +603,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -638,11 +638,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -673,11 +673,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-rename/SKILL.md === --- name: gortex-rename @@ -709,11 +709,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/refactoring/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -744,11 +744,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === project/home/.hermes/skills/testing/gortex-add-test/SKILL.md === --- name: gortex-add-test @@ -780,11 +780,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/config.yaml === mcp_servers: gortex: @@ -832,11 +832,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -867,11 +867,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -903,11 +903,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-impact/SKILL.md === --- name: gortex-impact @@ -939,11 +939,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -974,11 +974,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1010,11 +1010,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/analysis/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1045,11 +1045,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/code-intelligence/gortex-cli/SKILL.md === --- name: gortex-cli @@ -1088,11 +1088,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/code-intelligence/gortex/SKILL.md === --- name: gortex @@ -1176,11 +1176,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/debugging/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1212,11 +1212,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -1248,11 +1248,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -1284,11 +1284,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1319,11 +1319,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/navigation/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1354,11 +1354,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1390,11 +1390,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1425,11 +1425,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1460,11 +1460,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1496,11 +1496,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/refactoring/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1531,11 +1531,11 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.hermes/skills/testing/gortex-add-test/SKILL.md === --- name: gortex-add-test @@ -1567,8 +1567,8 @@ metadata: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. diff --git a/cmd/gortex/testdata/agent-render/opencode.txt b/cmd/gortex/testdata/agent-render/opencode.txt index b9038ec84..16a68158a 100644 --- a/cmd/gortex/testdata/agent-render/opencode.txt +++ b/cmd/gortex/testdata/agent-render/opencode.txt @@ -59,11 +59,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-architecture-review.md === --- description: "Graph-grounded architectural read of a repo." @@ -87,11 +87,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-co-change.md === --- description: "Find what changes together and hidden coupling." @@ -115,11 +115,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-cross-repo-usage.md === --- description: "Find who uses a symbol across all consumer repos." @@ -144,11 +144,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-dataflow-trace.md === --- description: "Trace where a value flows through the code." @@ -173,11 +173,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-debug.md === --- description: "Debug a bug, trace an error, or find why something fails." @@ -202,11 +202,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-episode-replay.md === --- description: "Reconstruct what changed in a window (postmortem, release, PR)." @@ -231,11 +231,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-explore.md === --- description: "Understand how code works or trace an execution flow." @@ -259,11 +259,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-extract-function.md === --- description: "Extract code into a function or method via LSP refactor." @@ -288,11 +288,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-fix-all.md === --- description: "Clear LSP diagnostics — one error, a file, or source.fixAll." @@ -316,11 +316,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-guide.md === --- description: "Gortex reference: available tools, graph schema, and workflow." @@ -348,11 +348,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-impact.md === --- description: "Assess what breaks if you change X before editing it." @@ -377,11 +377,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-incident-investigation.md === --- description: "Walk a production symptom back to its root cause." @@ -406,11 +406,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-onboarding.md === --- description: "Structured tour of an unfamiliar repo." @@ -434,11 +434,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-pr-review-agent.md === --- description: "Coding-agent review verdict via the gortex review verb." @@ -462,11 +462,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-pr-review.md === --- description: "Graph-grounded review of a pending change or PR." @@ -491,11 +491,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-quality-audit.md === --- description: "Repo-scale quality scan — dead code, hotspots, clones." @@ -519,11 +519,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-refactor.md === --- description: "Rename, extract, split, or restructure code safely." @@ -547,11 +547,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-rename.md === --- description: "Rename a symbol and update every reference atomically." @@ -576,11 +576,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/commands/gortex-safe-edit.md === --- description: "Preview an edit's blast radius on the shadow graph before writing." @@ -604,11 +604,11 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/opencode.json === { "$schema": "https://opencode.ai/config.json", @@ -1128,11 +1128,11 @@ description: "Add tests for under-tested code and coverage gaps." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-architecture-review/SKILL.md === --- name: gortex-architecture-review @@ -1157,11 +1157,11 @@ description: "Graph-grounded architectural read of a repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-cli/SKILL.md === --- name: gortex-cli @@ -1194,11 +1194,11 @@ The required order is `explore` → targeted `search/read/relations/trace` → p ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -1223,11 +1223,11 @@ description: "Find what changes together and hidden coupling." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -1253,11 +1253,11 @@ description: "Find who uses a symbol across all consumer repos." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -1283,11 +1283,11 @@ description: "Trace where a value flows through the code." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-debug/SKILL.md === --- name: gortex-debug @@ -1313,11 +1313,11 @@ description: "Debug a bug, trace an error, or find why something fails." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -1343,11 +1343,11 @@ description: "Reconstruct what changed in a window (postmortem, release, PR)." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1372,11 +1372,11 @@ description: "Understand how code works or trace an execution flow." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1402,11 +1402,11 @@ description: "Extract code into a function or method via LSP refactor." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1431,11 +1431,11 @@ description: "Clear LSP diagnostics — one error, a file, or source.fixAll." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-guide/SKILL.md === --- name: gortex-guide @@ -1464,11 +1464,11 @@ Use this sequence for codebase work: ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-impact/SKILL.md === --- name: gortex-impact @@ -1494,11 +1494,11 @@ description: "Assess what breaks if you change X before editing it." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -1524,11 +1524,11 @@ description: "Walk a production symptom back to its root cause." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1553,11 +1553,11 @@ description: "Structured tour of an unfamiliar repo." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -1582,11 +1582,11 @@ In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actio ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -1612,11 +1612,11 @@ description: "Graph-grounded review of a pending change or PR." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -1641,11 +1641,11 @@ description: "Repo-scale quality scan — dead code, hotspots, clones." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1670,11 +1670,11 @@ description: "Rename, extract, split, or restructure code safely." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1700,11 +1700,11 @@ description: "Rename a symbol and update every reference atomically." ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. === global/home/.config/opencode/skills/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1729,8 +1729,8 @@ description: "Preview an edit's blast radius on the shadow graph before writing. ## Worktree and branch routing -- The session/CWD selects the view. A session-discovered checkout (a linked worktree included) is an automatic overlay; do not `track` it. Only an explicit user request to track creates a dedicated logical graph. -- **Overall** means the selected overlay plus exactly one designated primary in its Git family, never a union of incompatible branches. Unique overlay/base matches keep normal relevance order; for a duplicate identity the overlay wins, and deletion/tombstone masks hide the base copy. -- Freshness `exact:false` = the requested view was not served. Every fallback is read-only. `require_exact:true` refuses substitution; `require_fresh:true` awaits filesystem state under RFC3339 `wait_deadline`, routed automatic checkouts only; else `fresh:false`. -- Select explicitly when CWD is not the target: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable committed structural/source snapshots: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback and ref/commit views are not. -- Untracking a non-primary dedicated worktree demotes it to automatic only if another ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. +- CWD/session selects the view. Discovered checkouts (including linked worktrees) use an automatic overlay; don't `track` them. An explicit user request to track creates a dedicated graph. +- **Overall**: selected overlay plus exactly one designated primary in its Git family; never incompatible branches. Unique overlay/base matches keep normal relevance order; duplicate identity: overlay wins; deletion/tombstone masks hide the base copy. +- Every fallback is read-only (`exact:false`); `require_exact:true` refuses it. `readback_exact:true`: continue, no polling. Check `graph_freshness`; pending files are not dead. Rename/delete/signature edits: `require_fresh:true`, `freshness_scope:"relations"`, RFC3339 `wait_deadline`. Waits cover watched primaries and routed automatic checkouts; ref/commit: `fresh:false`. +- Target another CWD: `view:{kind:"worktree",checkout_id:"…"}` or `view:{kind:"git_ref",value:"refs/heads/release",graph_id:"…"}`. Inactive ref/commit views are immutable source/structure: no working-copy LSP, `search.text`, or edits. Approved coordinator-backed exact worktree edits are supported; fallback/ref edits are not. +- Untracking a non-primary dedicated worktree demotes it to automatic only if a ready primary survives. Before primary closure, family forget, or `set-primary`, preview the effects and obtain user confirmation. From 0d062c9071d16f1a303379099bd5babaf4d9f6e5 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:40:55 +0100 Subject: [PATCH 053/138] mcp: reuse request-aware graph path normalization for freshness scope --- internal/mcp/relation_freshness.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index 9f580bc56..b5ae5ac8d 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "sort" "strings" "time" @@ -120,17 +119,7 @@ func (s *Server) laggingMutationReceipts(ctx context.Context) []*mutationReceipt func (s *Server) relationScope(ctx context.Context, ids, files []string) func(absPath string) bool { scoped := make(map[string]bool, len(files)+len(ids)) for _, file := range files { - if strings.HasPrefix(file, "file://") { - if abs, err := s.resolveOverlayAbsPath(file); err == nil { - file = abs - } - } - if filepath.IsAbs(file) { - file = s.repoRelative(file) - } else { - file = filepath.ToSlash(filepath.Clean(file)) - } - scoped[file] = true + scoped[s.graphRelPath(ctx, file)] = true } var names []string reader := s.readerFor(ctx) From 18e6b10bf7a54e601aec852dce057973a719ee16 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:42:45 +0100 Subject: [PATCH 054/138] test: exercise the tier knob through every guarded tool --- internal/mcp/checkout_binding.go | 6 +++--- internal/mcp/view_freshness_test.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/mcp/checkout_binding.go b/internal/mcp/checkout_binding.go index 9f1321615..5cb20021a 100644 --- a/internal/mcp/checkout_binding.go +++ b/internal/mcp/checkout_binding.go @@ -15,7 +15,7 @@ import ( "github.com/zzet/gortex/internal/pathkey" ) -// The three request-level freshness knobs. They are arguments of the request +// Request-level freshness knobs. They are arguments of the request // rather than of any one tool — the middleware reads them for every tool, no // handler ever does — so they are named once here. const ( @@ -29,7 +29,7 @@ const ( // them. var requestFreshnessArgNames = []string{requireExactArgName, requireFreshArgName, waitDeadlineArgName, freshnessScopeArgName} -// requestFreshnessArgKeys is THE allowlist that makes the three knobs callable +// requestFreshnessArgKeys is THE allowlist that makes these knobs callable // on every tool. It is the single place the set is enumerated for dispatch: // wrapToolArgGuard consults it (arg_schema_guard.go) so a guarded tool's closed // schema cannot refuse a knob the request middleware honours, and the tests @@ -156,7 +156,7 @@ func (f requestFreshness) effectiveDeadline(now time.Time, ctx context.Context) return deadline } -// takeRequestFreshness parses the three request-level knobs. +// takeRequestFreshness parses the request-level knobs. // // It deliberately does not strip them from the argument map: require_exact has // always travelled through to the handler and every tool ignores an argument diff --git a/internal/mcp/view_freshness_test.go b/internal/mcp/view_freshness_test.go index a4a69ee34..6577624c5 100644 --- a/internal/mcp/view_freshness_test.go +++ b/internal/mcp/view_freshness_test.go @@ -131,6 +131,9 @@ func TestViewsGuideStatesTheRequestLevelFreshnessContract(t *testing.T) { // knobValue is a well-formed value for one request-level knob. func knobValue(knob string) any { + if knob == freshnessScopeArgName { + return "file" + } if knob == waitDeadlineArgName { return time.Now().Add(time.Minute).Format(time.RFC3339) } From 78c8e9a5154b7efcbffe559c735320f8ecebbe99 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:45:09 +0100 Subject: [PATCH 055/138] mcp: require content evidence to retire failed publication hints --- internal/mcp/mutation_freshness_hints.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/mcp/mutation_freshness_hints.go b/internal/mcp/mutation_freshness_hints.go index d593810e7..a15763a8e 100644 --- a/internal/mcp/mutation_freshness_hints.go +++ b/internal/mcp/mutation_freshness_hints.go @@ -25,7 +25,6 @@ func mutationFreshnessRecoveryHint() string { reserved.operatorReconcile, reserved.automaticReconcile, reserved.retryExhausted, - "receipt retention lapsing as a last resort", } available := hints[:0] for _, hint := range hints { From 213d02aa77902740199aee3af54f080168fea191 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:48:46 +0100 Subject: [PATCH 056/138] bench: retain foreign impact RPCs across explicit active edit windows --- bench/load/foreign_impact.go | 252 ++++++++++++++++++++++++++++++ bench/load/foreign_impact_test.go | 187 ++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 bench/load/foreign_impact.go create mode 100644 bench/load/foreign_impact_test.go diff --git a/bench/load/foreign_impact.go b/bench/load/foreign_impact.go new file mode 100644 index 000000000..426b0f1b7 --- /dev/null +++ b/bench/load/foreign_impact.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "math" + "path/filepath" + "slices" + "time" +) + +const foreignImpactDuration = 10 * time.Minute + +// The driver must supply an independently evidenced offered-edit window. +// Timestamp membership here does not certify its traffic or the 600s gate. +type foreignImpactWindow struct { + ID string `json:"id"` + EditedRepo string `json:"edited_repo"` + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +type foreignImpactSample struct { + WindowID string `json:"window_id"` + TargetRepo string `json:"target_repo"` + EditedRepo string `json:"edited_repo"` + TargetSymbol string `json:"target_symbol"` + RPCID int64 `json:"rpc_id"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished"` + WithinWindow bool `json:"within_window"` + Latency Metric `json:"latency_ms"` + GateWait Metric `json:"gate_wait_ms"` + ResolveMutexWait Metric `json:"resolve_mutex_wait_ms"` + Refused bool `json:"refused"` + Error string `json:"error,omitempty"` + ErrorCode *int `json:"error_code,omitempty"` + ErrorData map[string]any `json:"error_data,omitempty"` + Raw json.RawMessage `json:"raw"` +} + +// observeForeignImpact uses a separate target-owned session and sends exactly +// one public impact RPC. It never retries or hides refusals behind a success. +func observeForeignImpact(ctx context.Context, client *socketClient, target, edited privateRepo, window foreignImpactWindow) foreignImpactSample { + symbol := target.Prefix + "/" + target.Corpus.Symbol + invalid := func(message string) foreignImpactSample { + return foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, nil, wireCallIdentity{}, errors.New(message)) + } + if client == nil || client.cwd == "" || target.Path == "" || filepath.Clean(client.cwd) != filepath.Clean(target.Path) { + return invalid("foreign impact requires its target checkout's own query session") + } + if target.Prefix == "" || target.Corpus.Symbol == "" || target.Corpus.Label == "" || edited.Corpus.Label == "" || edited.Path == "" || target.Corpus.Label == edited.Corpus.Label || filepath.Clean(target.Path) == filepath.Clean(edited.Path) { + return invalid("foreign impact requires distinct target and edited repositories") + } + if window.ID == "" || window.EditedRepo != edited.Corpus.Label || window.Start.IsZero() || !window.End.After(window.Start) { + return invalid("foreign impact has no valid owned edit window") + } + args := map[string]any{"operation": "impact", "target": map[string]any{"symbol": symbol}, "output": map[string]any{"format": "json", "max_bytes": 0}} + raw, identity, err := client.callWithIdentity(ctx, "tools/call", map[string]any{"name": "change", "arguments": args}) + return foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, raw, identity, err) +} + +func foreignImpactObservation(window foreignImpactWindow, targetRepo, editedRepo, symbol string, raw json.RawMessage, identity wireCallIdentity, callErr error) foreignImpactSample { + row := foreignImpactSample{WindowID: window.ID, TargetRepo: targetRepo, EditedRepo: editedRepo, TargetSymbol: symbol, RPCID: identity.ID, Started: identity.Started, Finished: identity.Finished, Raw: append(json.RawMessage(nil), raw...), + Latency: unavailable("ms", "RPC timestamps unavailable"), GateWait: unavailable("ms", "ABSENT: no impact topology-gate wait producer"), ResolveMutexWait: unavailable("ms", "ABSENT: no impact resolve-mutex wait producer")} + if identity.ID != 0 && !identity.Started.IsZero() && !identity.Finished.Before(identity.Started) { + row.Latency = measured(float64(identity.Finished.Sub(identity.Started))/float64(time.Millisecond), "ms") + row.WithinWindow = window.ID != "" && window.EditedRepo == editedRepo && targetRepo != "" && targetRepo != editedRepo && !window.Start.IsZero() && window.End.After(window.Start) && !identity.Started.Before(window.Start) && !identity.Finished.After(window.End) + } + var remote *rpcError + row.Refused = errors.As(callErr, &remote) + if remote != nil { + code := remote.Code + row.ErrorCode = &code + row.ErrorData = copyRPCErrorData(remote.Data) + } + if len(raw) > 0 { + var envelope struct { + IsError bool `json:"isError"` + Meta map[string]any `json:"_meta"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + callErr = errors.Join(callErr, err) + } else { + row.GateWait = foreignImpactWait(envelope.Meta, "gate_wait") + row.ResolveMutexWait = foreignImpactWait(envelope.Meta, "resolve_mutex_wait") + if envelope.IsError { + row.Refused = true + callErr = errors.Join(callErr, checkToolResult(raw)) + } + } + } else if callErr == nil { + callErr = errors.New("impact RPC returned no raw response") + } + if callErr != nil { + row.Error = callErr.Error() + } + return row +} + +type foreignImpactPairSummary struct { + TargetRepo string `json:"target_repo"` + EditedRepo string `json:"edited_repo"` + Summary foreignImpactSummary `json:"summary"` +} + +type foreignImpactSummary struct { + Offered int `json:"offered"` + Pairs []foreignImpactPairSummary `json:"pairs,omitempty"` + WithinWindow int `json:"within_window"` + OutsideWindow int `json:"outside_window"` + Unavailable int `json:"unavailable"` + Refusals int `json:"refusals"` + Errors int `json:"errors"` + Latency Distribution `json:"latency_ms"` + GateWait Distribution `json:"gate_wait_ms"` + ResolveMutexWait Distribution `json:"resolve_mutex_wait_ms"` + GateWaitUnavailable int `json:"gate_wait_unavailable"` + ResolveMutexWaitUnavailable int `json:"resolve_mutex_wait_unavailable"` +} + +// All offers remain counted. The window distribution includes failed and +// refused RPCs; unavailable timestamps remain explicit rather than zero. +func summarizeForeignImpact(rows []foreignImpactSample) foreignImpactSummary { + out := summarizeForeignImpactRows(rows) + grouped := make(map[string][]foreignImpactSample) + for _, row := range rows { + key := row.TargetRepo + "\x00" + row.EditedRepo + grouped[key] = append(grouped[key], row) + } + keys := make([]string, 0, len(grouped)) + for key := range grouped { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + group := grouped[key] + out.Pairs = append(out.Pairs, foreignImpactPairSummary{TargetRepo: group[0].TargetRepo, EditedRepo: group[0].EditedRepo, Summary: summarizeForeignImpactRows(group)}) + } + return out +} + +func summarizeForeignImpactRows(rows []foreignImpactSample) foreignImpactSummary { + out := foreignImpactSummary{Offered: len(rows)} + var values, gateWaits, resolveWaits []float64 + for _, row := range rows { + if row.Error != "" { + out.Errors++ + } + if row.Refused { + out.Refusals++ + } + if row.Latency.Value == nil { + out.Unavailable++ + } + if row.WithinWindow { + out.WithinWindow++ + if row.Latency.Value != nil { + values = append(values, *row.Latency.Value) + } + if row.GateWait.Value != nil { + gateWaits = append(gateWaits, *row.GateWait.Value) + } else { + out.GateWaitUnavailable++ + } + if row.ResolveMutexWait.Value != nil { + resolveWaits = append(resolveWaits, *row.ResolveMutexWait.Value) + } else { + out.ResolveMutexWaitUnavailable++ + } + } else { + out.OutsideWindow++ + } + } + out.Latency = distribution(values, "ms") + out.GateWait = distribution(gateWaits, "ms") + out.ResolveMutexWait = distribution(resolveWaits, "ms") + return out +} + +// Only a complete producer observation can supply a wait sample. Detached, +// omitted, malformed, and interrupted observations never become measured zero. +func foreignImpactWait(meta map[string]any, name string) Metric { + waits, _ := meta["reach_coordination"].(map[string]any) + phase, _ := waits[name].(map[string]any) + if phase == nil { + return unavailable("ms", "ABSENT: impact coordination producer omits "+name) + } + state, _ := phase["state"].(string) + if state != "observed" { + return unavailable("ms", "producer wait state "+state) + } + attempted, attemptedOK := phase["attempted"].(float64) + acquired, acquiredOK := phase["acquired"].(float64) + ms, msOK := phase["total_ms"].(float64) + if !attemptedOK || !acquiredOK || attempted < 1 || attempted != math.Trunc(attempted) || acquired != attempted || !msOK || math.IsNaN(ms) || math.IsInf(ms, 0) || ms < 0 { + return unavailable("ms", "invalid or incomplete producer wait observation") + } + return measured(ms, "ms") +} + +type foreignImpactTarget struct { + Client *socketClient + Repo privateRepo +} + +// runForeignImpactStream reuses the existing relation-prober cadence. It is a +// single query stream, never a new editor schedule. emit must retain every row; +// a persistence failure terminates evidence instead of hiding observations. +func runForeignImpactStream(ctx context.Context, targets []foreignImpactTarget, edited privateRepo, window foreignImpactWindow, emit func(foreignImpactSample) error) (foreignImpactSummary, error) { + if len(targets) == 0 || emit == nil || window.Start.IsZero() || !window.End.After(window.Start) || window.End.Sub(window.Start) > foreignImpactDuration { + return summarizeForeignImpact(nil), errors.New("foreign impact requires a bounded window and raw evidence sink") + } + if err := waitContext(ctx, time.Until(window.Start)); err != nil { + return summarizeForeignImpact(nil), err + } + probeCtx, cancel := context.WithDeadline(ctx, window.End) + defer cancel() + ticker := time.NewTicker(editProbePeriod) + defer ticker.Stop() + // Only scalar samples are retained here; raw responses belong to emit. + var rows []foreignImpactSample + for time.Now().Before(window.End) && ctx.Err() == nil { + target := targets[len(rows)%len(targets)] + row := observeForeignImpact(probeCtx, target.Client, target.Repo, edited, window) + retained := row + row.Raw = nil + row.ErrorData = nil + row.ErrorCode = nil + if row.Error != "" { + row.Error = "observed product error; raw sink carries details" + } + row.Latency.Reason, row.GateWait.Reason, row.ResolveMutexWait.Reason = "", "", "" + rows = append(rows, row) + if err := emit(retained); err != nil { + return summarizeForeignImpact(rows), err + } + if row.RPCID == 0 { + return summarizeForeignImpact(rows), errors.New(row.Error) + } + select { + case <-probeCtx.Done(): + if ctx.Err() != nil { + return summarizeForeignImpact(rows), ctx.Err() + } + return summarizeForeignImpact(rows), nil + case <-ticker.C: + } + } + return summarizeForeignImpact(rows), ctx.Err() +} diff --git a/bench/load/foreign_impact_test.go b/bench/load/foreign_impact_test.go new file mode 100644 index 000000000..c7cd96ac1 --- /dev/null +++ b/bench/load/foreign_impact_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" +) + +func TestForeignImpactRetainsRefusalAndOwnedRPC(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "file.go::Target"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + if f.Method != "tools/call" { + return map[string]any{}, nil + } + args, _ := f.Params["arguments"].(map[string]any) + got, _ := args["target"].(map[string]any) + if f.Cwd != target.Path || f.Params["name"] != "change" || args["operation"] != "impact" || got["symbol"] != "target/file.go::Target" { + t.Errorf("foreign impact dispatched to wrong route: %+v", f) + } + return map[string]any{"isError": true, "content": []any{map[string]any{"type": "text", "text": "graph freshness unavailable: relations pending"}}}, nil + }) + client, err := openMCP(t.Context(), socket, target.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + window := foreignImpactWindow{ID: "active-edits", EditedRepo: "edited", Start: time.Now().Add(-time.Second), End: time.Now().Add(time.Minute)} + row := observeForeignImpact(t.Context(), client, target, edited, window) + if !row.WithinWindow || !row.Refused || row.Error == "" || row.RPCID == 0 || len(row.Raw) == 0 || row.Latency.Value == nil { + t.Fatalf("refusal or exact RPC evidence was lost: %+v", row) + } + if row.TargetRepo != "target" || row.EditedRepo != "edited" || row.TargetSymbol != "target/file.go::Target" { + t.Fatalf("lost repository attribution: %+v", row) + } + if row.GateWait.Value != nil || row.ResolveMutexWait.Value != nil { + t.Fatalf("missing daemon waits were invented: %+v", row) + } + summary := summarizeForeignImpact([]foreignImpactSample{row}) + if summary.WithinWindow != 1 || summary.Refusals != 1 || summary.Errors != 1 || summary.Latency.Count != 1 { + t.Fatalf("refusal was excluded from denominator: %+v", summary) + } +} + +func TestForeignImpactRejectsBorrowedSessionAndSameRepository(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "a.go::Target"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now().Add(-time.Second), End: time.Now().Add(time.Second)} + for _, tc := range []struct { + name string + client *socketClient + edited privateRepo + }{ + {"borrowed session", &socketClient{cwd: edited.Path}, edited}, + {"same repository", &socketClient{cwd: target.Path}, target}, + {"nil session", nil, edited}, + } { + t.Run(tc.name, func(t *testing.T) { + row := observeForeignImpact(t.Context(), tc.client, target, tc.edited, window) + if row.Error == "" || row.RPCID != 0 || row.Latency.Value != nil || row.WithinWindow { + t.Fatalf("invalid route supplied evidence: %+v", row) + } + }) + } +} + +func TestForeignImpactWindowKeepsFailuresAndBoundaryCalls(t *testing.T) { + start := time.Now() + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: start, End: start.Add(time.Minute)} + rows := []foreignImpactSample{ + foreignImpactObservation(window, "target", "edited", "target/a.go::A", json.RawMessage(`{"isError":false,"content":[{"text":"{}"}]}`), wireCallIdentity{ID: 1, Started: start, Finished: start.Add(time.Millisecond)}, nil), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 2, Started: start.Add(time.Second), Finished: start.Add(2 * time.Second)}, context.DeadlineExceeded), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 3, Started: start.Add(-time.Second), Finished: start.Add(time.Second)}, errors.New("connection lost")), + foreignImpactObservation(window, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 4, Started: start.Add(59 * time.Second), Finished: start.Add(61 * time.Second)}, errors.New("late error")), + } + summary := summarizeForeignImpact(rows) + if summary.Offered != 4 || summary.WithinWindow != 2 || summary.OutsideWindow != 2 || summary.Errors != 3 || summary.Latency.Count != 2 { + t.Fatalf("hidden exclusion/boundary: %+v", summary) + } + missing := summarizeForeignImpact([]foreignImpactSample{{Error: "no connection", Latency: unavailable("ms", "no RPC")}}) + if missing.Latency.P95.Value != nil { + t.Fatalf("missing observation became zero: %+v", missing) + } + +} + +func TestForeignImpactWaitDecoderUsesOnlyObservedCompleteProducer(t *testing.T) { + for _, tc := range []struct { + state string + attempted, acquired float64 + want bool + }{ + {"observed", 1, 1, true}, {"not_applicable_detached", 0, 0, false}, {"not_observed", 0, 0, false}, {"censored", 1, 0, false}, {"observed", 0, 0, false}, {"observed", 2, 1, false}, + } { + t.Run(tc.state, func(t *testing.T) { + meta := map[string]any{"reach_coordination": map[string]any{"resolve_mutex_wait": map[string]any{"state": tc.state, "attempted": tc.attempted, "acquired": tc.acquired, "total_ms": float64(4)}}} + got := foreignImpactWait(meta, "resolve_mutex_wait") + if (got.Value != nil) != tc.want { + t.Fatalf("producer state %s became %+v", tc.state, got) + } + if got.Value != nil && *got.Value != 4 { + t.Fatalf("producer duration altered: %+v", got) + } + }) + } +} + +func TestForeignImpactPreservesStructuredRPCRefusalData(t *testing.T) { + start := time.Now() + data := map[string]any{"error_code": "graph_freshness_unavailable", "details": map[string]any{"pending_files": []any{"target/a.go"}}} + remote := &rpcError{Code: -32000, Message: "stale graph", Data: data} + row := foreignImpactObservation(foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: start, End: start.Add(time.Minute)}, "target", "edited", "target/a.go::A", nil, wireCallIdentity{ID: 1, Started: start, Finished: start.Add(time.Millisecond)}, remote) + if !row.Refused || row.ErrorCode == nil || *row.ErrorCode != -32000 || row.ErrorData["error_code"] != "graph_freshness_unavailable" { + t.Fatalf("typed refusal lost: %+v", row) + } + data["error_code"] = "mutated" + data["details"].(map[string]any)["pending_files"].([]any)[0] = "other.go" + if row.ErrorData["error_code"] != "graph_freshness_unavailable" || row.ErrorData["details"].(map[string]any)["pending_files"].([]any)[0] != "target/a.go" { + t.Fatal("retained refusal borrowed mutable RPC data") + } +} + +func TestForeignImpactStreamFailsClosedWhenRawEvidenceCannotBeRetained(t *testing.T) { + root := t.TempDir() + target := privateRepo{Path: filepath.Join(root, "target"), Prefix: "target", Corpus: Corpus{Label: "target", Symbol: "a.go::A"}} + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + return map[string]any{"content": []any{map[string]any{"type": "text", "text": "{}"}}}, nil + }) + client, err := openMCP(t.Context(), socket, target.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now(), End: time.Now().Add(time.Minute)} + emitted := 0 + diskErr := errors.New("raw evidence writer failed") + summary, err := runForeignImpactStream(t.Context(), []foreignImpactTarget{{Client: client, Repo: target}}, edited, window, func(row foreignImpactSample) error { + emitted++ + if row.RPCID == 0 || len(row.Raw) == 0 { + t.Error("writer received no actual RPC evidence") + } + return diskErr + }) + if !errors.Is(err, diskErr) || emitted != 1 || summary.Offered != 1 { + t.Fatalf("evidence failure was hidden: emitted=%d err=%v", emitted, err) + } +} + +func TestForeignImpactStreamRotatesDistinctOwnedTargets(t *testing.T) { + root := t.TempDir() + edited := privateRepo{Path: filepath.Join(root, "edited"), Prefix: "edited", Corpus: Corpus{Label: "edited"}} + socket := startStub(t, func(f stubFrame) (any, *rpcError) { + return map[string]any{"content": []any{map[string]any{"type": "text", "text": "{}"}}}, nil + }) + var targets []foreignImpactTarget + for _, name := range []string{"target-one", "target-two"} { + repo := privateRepo{Path: filepath.Join(root, name), Prefix: name, Corpus: Corpus{Label: name, Symbol: "a.go::A"}} + client, err := openMCP(t.Context(), socket, repo.Path) + if err != nil { + t.Fatal(err) + } + defer client.Close() + targets = append(targets, foreignImpactTarget{Client: client, Repo: repo}) + } + stop := errors.New("three-row diagnostic complete") + var order []string + window := foreignImpactWindow{ID: "active", EditedRepo: "edited", Start: time.Now(), End: time.Now().Add(time.Minute)} + summary, err := runForeignImpactStream(t.Context(), targets, edited, window, func(row foreignImpactSample) error { + order = append(order, row.TargetRepo) + if len(order) == 3 { + return stop + } + return nil + }) + if len(summary.Pairs) != 2 || summary.Pairs[0].Summary.Offered != 2 || summary.Pairs[1].Summary.Offered != 1 { + t.Fatalf("pooled queries hid individual pair denominators: %+v", summary.Pairs) + } + if !errors.Is(err, stop) || summary.Offered != 3 || len(order) != 3 || order[0] != "target-one" || order[1] != "target-two" || order[2] != "target-one" { + t.Fatalf("query targets were not independently rotated: order=%v summary=%+v err=%v", order, summary, err) + } +} From 7f142e65e5d8c4627a2cd4c85f1b8c5f2b26624c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:51:36 +0100 Subject: [PATCH 057/138] test: cover explicit reindex watcher failure recovery --- internal/indexer/mutation_tiers.go | 5 ++++ internal/indexer/mutation_tiers_test.go | 37 +++++++++++++++++++++++++ internal/mcp/tools_core_reindex_test.go | 16 +++++++++++ 3 files changed, 58 insertions(+) diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index 5efcee81a..8ff9822cb 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -143,6 +143,11 @@ func (w *Watcher) TierState(path string) MutationTierState { return state } +// CaptureTierRepair binds a successful explicit single-file reparse to the +// failure it observed before starting. The caller invokes it only after proof +// that this exact file was reparsed successfully. +func (w *Watcher) CaptureTierRepair(_ string) func() { return nil } + func (mw *MultiWatcher) TierPaths() map[string]MutationTierState { mw.mu.Lock() watchers := make([]*Watcher, 0, len(mw.watchers)) diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index b9f8a640d..6d2b3b5ed 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -91,6 +91,43 @@ func TestIdentityPatchPublishesTicketTiers(t *testing.T) { require.False(t, tiers.Failed) } +func TestExplicitTierRepairIsBoundToFailedGeneration(t *testing.T) { + for _, scenario := range []string{"same_failure", "newer_failure", "partial", "newer_pending"} { + t.Run(scenario, func(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\nfunc Main() {}\n") + path := filepath.Join(dir, "main.go") + failure := MutationTierState{Generation: 7, Failed: true, failedAt: time.Now()} + if scenario == "partial" { + failure.Failed, failure.Partial = false, true + } + watcher.failedPaths = map[string]MutationTierState{path: failure} + repair := watcher.CaptureTierRepair(path) + if scenario == "newer_failure" { + failure.Generation++ + watcher.failedPaths[path] = failure + } + if scenario == "newer_pending" { + ticket := newMutationTierTicket(path, 8, nil) + watcher.mutationWaiters[path] = map[uint64]mutationWaiter{8: {ticket: ticket}} + } + if repair != nil { + repair() + } + switch scenario { + case "same_failure": + require.NotContains(t, watcher.TierPaths(), path, "an explicit successful reparse repairs the captured failure") + case "newer_failure": + require.Equal(t, failure, watcher.TierState(path), "the pass did not observe this later failure") + case "partial": + require.True(t, watcher.TierState(path).Partial, "a file parse alone cannot certify bounded derived fanout") + case "newer_pending": + require.Equal(t, uint64(8), watcher.TierState(path).Generation) + require.NotContains(t, watcher.failedPaths, path) + } + }) + } +} + func TestStartupRelationsRemainPendingAfterCancelledBatchReset(t *testing.T) { mi := &MultiIndexer{} mi.BeginParallelBatch() diff --git a/internal/mcp/tools_core_reindex_test.go b/internal/mcp/tools_core_reindex_test.go index 168d5cc6a..3445f9cd3 100644 --- a/internal/mcp/tools_core_reindex_test.go +++ b/internal/mcp/tools_core_reindex_test.go @@ -74,6 +74,10 @@ func TestHandleReindexRepository_ResolvesFailedReceiptForTheReparsedPath(t *test _, err := idx.Index(dir) require.NoError(t, err) srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + watcher := &reindexTierFixture{watchedTierFixture: watchedTierFixture{ + paths: map[string]indexer.MutationTierState{target: {Generation: 4, Failed: true}}, + }} + srv.SetWatcher(watcher) // A failed ingest for that exact path, plus one for a file the pass will // not touch: only the re-parsed path may be resolved. @@ -104,6 +108,18 @@ func TestHandleReindexRepository_ResolvesFailedReceiptForTheReparsedPath(t *test if outcome := untouched.outcome(false); outcome.Err == nil { t.Fatal("reindex resolved a receipt for a path it never re-parsed") } + require.Equal(t, target, watcher.captured, "the recovery frontier must be captured before the pass") + require.Empty(t, watcher.TierPaths(), "watcher failure evidence must be repaired alongside the MCP receipt") +} + +type reindexTierFixture struct { + watchedTierFixture + captured string +} + +func (w *reindexTierFixture) CaptureTierRepair(path string) func() { + w.captured = path + return func() { delete(w.paths, path) } } func TestHandleReindexRepository_WholeRepoSingleMode(t *testing.T) { From 6b6b6b1d898c73c510998f41d087e80203913d60 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:53:40 +0100 Subject: [PATCH 058/138] fix: repair captured watcher failures after explicit reindex --- internal/indexer/mutation_tiers.go | 33 ++++++++++++++++++++++++++++-- internal/mcp/edit_serialization.go | 12 +++++++++++ internal/mcp/tools_core.go | 6 ++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index 8ff9822cb..7baa41737 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -145,8 +145,37 @@ func (w *Watcher) TierState(path string) MutationTierState { // CaptureTierRepair binds a successful explicit single-file reparse to the // failure it observed before starting. The caller invokes it only after proof -// that this exact file was reparsed successfully. -func (w *Watcher) CaptureTierRepair(_ string) func() { return nil } +// that this exact file was reparsed successfully. Bounded derived incompleteness +// is separate evidence and cannot be repaired by a file parse alone. +func (w *Watcher) CaptureTierRepair(path string) func() { + path = filepath.Clean(path) + w.mu.Lock() + observed := w.failedPaths[path] + w.mu.Unlock() + if !observed.Failed || observed.Partial { + return nil + } + return func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.failedPaths[path] == observed { + delete(w.failedPaths, path) + } + } +} + +func (mw *MultiWatcher) CaptureTierRepair(path string) func() { + mw.mu.Lock() + _, prefix := mw.multi.IndexerForFile(path) + watcher := mw.watchers[prefix] + mw.mu.Unlock() + if watcher != nil { + // Capture this exact owner now. Looking it up after the reparse could + // clear a replacement watcher's failure that the pass never observed. + return watcher.CaptureTierRepair(path) + } + return nil +} func (mw *MultiWatcher) TierPaths() map[string]MutationTierState { mw.mu.Lock() diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index 28b23d38a..a883ffdce 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -442,6 +442,18 @@ func (s *Server) failedReceiptsBefore(paths []string, root string) map[string]st return eligible } +func (s *Server) captureWatcherTierRepair(paths []string, root string) func() { + path, ok := reindexCandidatePath(paths, root) + if !ok { + return nil + } + watcher, ok := s.currentWatcher().(interface{ CaptureTierRepair(string) func() }) + if !ok { + return nil + } + return watcher.CaptureTierRepair(path) +} + func (s *Server) resolveReindexedPathReceipts(reindexedPath string, eligible map[string]struct{}) { cleanPath := filepath.Clean(reindexedPath) s.mutationReceipts.Range(func(_, value any) bool { diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index d6216f81e..b4947d818 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -1549,6 +1549,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe // the cutoff declines some safe resolutions, which is the right way // to be wrong here. eligible map[string]struct{} + repair func() ) // Multi-repo mode: route through the per-repo indexer so nodes keep @@ -1580,6 +1581,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError(scopeErr.Error()), nil } eligible = s.failedReceiptsBefore(paths, reindexRoot) + repair = s.captureWatcherTierRepair(paths, reindexRoot) result, err = s.multiIndexer.IncrementalReindexRepo(prefix, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1602,6 +1604,7 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe } reindexRoot = root eligible = s.failedReceiptsBefore(paths, reindexRoot) + repair = s.captureWatcherTierRepair(paths, reindexRoot) result, err = s.indexer.IncrementalReindexPaths(root, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1622,6 +1625,9 @@ func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRe // prevent — so anything wider is left alone. if resolved, ok := reindexedReceiptPath(paths, reindexRoot, result); ok { s.resolveReindexedPathReceipts(resolved, eligible) + if repair != nil { + repair() + } } s.startBackgroundAnalysis("reindex") From ac71bab4b5eaeacafa72af9224f451d07f436a5c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:57:17 +0100 Subject: [PATCH 059/138] style: use tagged switch for freshness tier selection --- internal/mcp/freshness_rider.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index f12262bf8..c0e35387a 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -597,9 +597,10 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } putTiers := func(path string, tiers indexer.MutationTierState) { at := tiers.RelationsAt - if scope == "file" { + switch scope { + case "file": at = tiers.FileGraphAt - } else if scope == "derived" { + case "derived": at = tiers.DerivedAt } state := "" From 0eff9cdcc38b8e4dbacc555759b5788e783af308 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:58:07 +0100 Subject: [PATCH 060/138] test: initialize pending watcher repair control --- internal/indexer/mutation_tiers_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/indexer/mutation_tiers_test.go b/internal/indexer/mutation_tiers_test.go index 6d2b3b5ed..00c5d5361 100644 --- a/internal/indexer/mutation_tiers_test.go +++ b/internal/indexer/mutation_tiers_test.go @@ -108,7 +108,9 @@ func TestExplicitTierRepairIsBoundToFailedGeneration(t *testing.T) { } if scenario == "newer_pending" { ticket := newMutationTierTicket(path, 8, nil) - watcher.mutationWaiters[path] = map[uint64]mutationWaiter{8: {ticket: ticket}} + watcher.mutationWaiters = map[string]map[uint64]mutationWaiter{ + path: {8: {ticket: ticket}}, + } } if repair != nil { repair() From a8ae14f1ca6350c3d3dd275283ced297f2da9f4b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 18:58:52 +0100 Subject: [PATCH 061/138] fix(search): preserve explicit layers through runtime readers --- internal/mcp/overlay_contract_bundle_test.go | 41 +++++++ internal/mcp/overlay_view.go | 27 ++++- internal/query/engine.go | 52 ++++----- internal/query/engine_layer_bundle_test.go | 106 +++++++++++++++++++ internal/query/engine_layers.go | 57 ++++++++++ internal/query/view_candidates.go | 5 + 6 files changed, 251 insertions(+), 37 deletions(-) create mode 100644 internal/mcp/overlay_contract_bundle_test.go create mode 100644 internal/query/engine_layer_bundle_test.go create mode 100644 internal/query/engine_layers.go diff --git a/internal/mcp/overlay_contract_bundle_test.go b/internal/mcp/overlay_contract_bundle_test.go new file mode 100644 index 000000000..5cc831fbc --- /dev/null +++ b/internal/mcp/overlay_contract_bundle_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" +) + +func TestContractRuntimeSearchPreservesSessionOverlayBundles(t *testing.T) { + for _, contract := range []bool{false, true} { + name := "plain_overlay" + if contract { + name = "contract_runtime" + } + t.Run(name, func(t *testing.T) { + old := &graph.Node{ID: "repo/edit.go::Widget", Name: "Widget", Kind: graph.KindFunction, FilePath: "repo/edit.go", StartLine: 3} + gone := &graph.Node{ID: "repo/edit.go::WidgetGone", Name: "WidgetGone", Kind: graph.KindFunction, FilePath: "repo/edit.go", StartLine: 8} + backend := &searchSymbolsContextBackend{bundles: []search.SymbolBundle{{Node: old, Score: 10}, {Node: gone, Score: 9}}} + server := newSearchSymbolsContextServer(backend) + base := server.graph.(*graph.Graph) + base.AddNode(old) + base.AddNode(gone) + layer := graph.NewOverlayLayer() + layer.MarkFile(old.FilePath, false) + replacement := *old + replacement.StartLine = 44 + layer.AddNode(old.FilePath, &replacement) + layer.MarkRemoved(gone.Name, gone.ID) + if contract { + server.contractAnalysisRuntime = &ContractAnalysisRuntime{} + } + ctx := WithOverlayView(t.Context(), graph.NewOverlaidView(base, layer)) + got := server.engineFor(ctx).GatherSymbolCandidates("Widget", 5, query.QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, nil) + require.Len(t, got, 1, "the actual request engine cannot revive an overlay-deleted bundle through its runtime reader wrapper") + require.Same(t, &replacement, got[0].Node, "the actual request engine must serve the session replacement") + }) + } +} diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go index faab2e970..c10dd2b76 100644 --- a/internal/mcp/overlay_view.go +++ b/internal/mcp/overlay_view.go @@ -227,11 +227,15 @@ func (s *Server) engineFor(ctx context.Context) *query.Engine { for _, analysis := range binding.views { layers = append(layers, query.ViewLayerSource{Search: analysis.SearchBackend(), Layer: binding.composedLayer}) } - return s.engine.WithComposedView(reader, layers, ctx, view.excludesBaseCorpus()) + ownership := s.requestLayers(ctx) + if binding.composedLayer != nil { + ownership = append(ownership, binding.composedLayer) + } + return s.engine.WithComposedView(reader, layers, ctx, view.excludesBaseCorpus()).WithLayers(ownership) } if s.contractAnalysisRuntime != nil { view := requestViewFromContext(ctx) - return s.engine.WithComposedView(s.readerFor(ctx), view.candidateLayers(), ctx, view.excludesBaseCorpus()) + return s.engine.WithComposedView(s.readerFor(ctx), view.candidateLayers(), ctx, view.excludesBaseCorpus()).WithLayers(s.requestLayers(ctx)) } // A routed or overlaid request engine is already a per-request clone, and // it carries the request's lifetime so its long graph walks stop when the @@ -240,10 +244,10 @@ func (s *Server) engineFor(ctx context.Context) *query.Engine { // context through QueryOptions.Context instead. view := requestViewFromContext(ctx) if v := OverlayViewFromContext(ctx); v != nil { - return s.engine.WithViewLayersContext(v, view.candidateLayers(), ctx) + return s.engine.WithViewLayersContext(v, view.candidateLayers(), ctx).WithLayers(s.requestLayers(ctx)) } if view.routed() { - return s.engine.WithComposedView(view.reader, view.candidateLayers(), ctx, view.excludesBaseCorpus()) + return s.engine.WithComposedView(view.reader, view.candidateLayers(), ctx, view.excludesBaseCorpus()).WithLayers(s.requestLayers(ctx)) } return s.engine } @@ -1527,3 +1531,18 @@ func (s *Server) validateOverlaySnapshotDrift(ctx context.Context, snapshot *ove return nil } + +// requestLayers carries the same ownership claims as readerFor, independently +// of runtime wrappers. Candidate corpora and row ownership are separate inputs. +func (s *Server) requestLayers(ctx context.Context) []graph.OverlayLayerReader { + var layers []graph.OverlayLayerReader + for _, source := range requestViewFromContext(ctx).candidateLayers() { + if source.Layer != nil { + layers = append(layers, source.Layer) + } + } + if overlay := OverlayViewFromContext(ctx); overlay != nil && overlay.Layer() != nil { + layers = append(layers, overlay.Layer()) + } + return layers +} diff --git a/internal/query/engine.go b/internal/query/engine.go index 94838da47..2ba0fafa6 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -48,6 +48,7 @@ type Engine struct { // pre-materialised (the bundle fast path) predates the overlay and // has to be re-read through the reader before it can be trusted. overlay *graph.OverlayLayer + layers []graph.OverlayLayerReader // viewLayers are the persisted generations stacked in the reader, // bottom first, empty for a plain base reader. Each carries its own // text corpus, so candidate enumeration queries them alongside the @@ -135,6 +136,7 @@ func (e *Engine) WithReader(r graph.Reader) *Engine { clone := *e clone.g = r clone.overlay = nil + clone.layers = nil // A reader swap re-decides which corpora answer, so the stack the // previous reader named never carries over — WithViewLayers is the // only way one is bound. @@ -142,6 +144,9 @@ func (e *Engine) WithReader(r graph.Reader) *Engine { clone.viewExcludesBase = false if view, ok := r.(overlayLayered); ok { clone.overlay = view.Layer() + if clone.overlay != nil { + clone.layers = []graph.OverlayLayerReader{clone.overlay} + } } return &clone } @@ -943,7 +948,7 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi bundleCtx = search.WithSymbolBundleTimingsObserver(ctx, timings.BundleLegs.Add) } var answer requestBundleAnswer - if len(opts.SearchPathPrefixes) > 0 && !viewLayered && e.overlay == nil { + if len(opts.SearchPathPrefixes) > 0 && !viewLayered && e.overlay == nil && len(e.layers) == 0 { answer = requestPathScopedSymbolBundles(bundleCtx, backend, query, repoAllowList(opts.RepoAllow), opts.SearchPathPrefixes, fetchLimit*2) } if allow := repoAllowList(opts.RepoAllow); !answer.authoritative && len(allow) > 0 { @@ -964,22 +969,14 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi if timings != nil { timings.BundleMS += time.Since(bundleStart).Milliseconds() } - // A per-request overlay reader invalidates the bundle payload: - // the backend indexes the base graph, so a symbol the buffer - // replaced arrives carrying its pre-edit node, and a symbol the - // buffer deleted arrives at all. Keep the BM25 ranking — the - // scores are still the corpus's answer — and drop the payload: - // every hit falls through to the view-aware batched lookup - // below, which substitutes the overlay's node and omits the IDs - // the view no longer exposes. - // - // A composed view invalidates it for the same reason one level - // down: the bundle's payload is the indexed corpus's row, and a - // generation in the stack may have replaced or deleted it. - rehydrateThroughReader := e.overlay != nil || viewLayered + // Ownership is explicit even when the runtime wraps the view reader. + // Keep each safe base payload and adjacency; only affected hits fall back + // to the composed reader. Partial adjacency seeds remain partial. + if len(bundles) > 0 { bundleHandled = true textResults = make([]search.SearchResult, 0, len(bundles)) + allEdgesSeeded := true outSeed := make(map[string][]*graph.Edge, len(bundles)) inSeed := make(map[string][]*graph.Edge, len(bundles)) for _, b := range bundles { @@ -987,31 +984,20 @@ func (e *Engine) gatherBackendCandidates(ctx context.Context, query string, limi continue } textResults = append(textResults, search.SearchResult{ID: b.Node.ID, Score: b.Score}) - if rehydrateThroughReader { + if !e.bundleNodeNeedsReader(b.Node) { + bundleNodeByID[b.Node.ID] = b.Node + } + if e.bundleEdgesNeedReader(b) { + allEdgesSeeded = false continue } - bundleNodeByID[b.Node.ID] = b.Node outSeed[b.Node.ID] = b.OutEdges inSeed[b.Node.ID] = b.InEdges } - // Seed the rerank context's edge caches so prepare() can - // skip its own batched fetch for the bundle-covered IDs. - // preSeeded=true is the contract that prepare's batched - // edge fetch is now redundant — see rerank.Context for the - // invariant the engine relies on (the next caller's - // candidate set is fully covered by these maps for the - // BM25 hits; vector / substring fallback hits are still - // served by the per-candidate accessor fallback). - // - // Under an overlay the seed is skipped entirely rather than - // filtered down to the uncovered files: the layer also - // *introduces* edges no base bundle carries, so no subset of - // base edges can honour the pre-seed contract. Dropping it - // hands the fetch back to the rerank pass, which reads - // through the same overlay-aware reader. - if rctx != nil && !rehydrateThroughReader { - rctx.SeedEdgeCaches(inSeed, outSeed, true) + if rctx != nil && len(outSeed) > 0 { + rctx.SeedEdgeCaches(inSeed, outSeed, allEdgesSeeded) } + } else if scopedAnswered { // A scoped query that answered zero IS the answer. Leaving // bundleHandled false here would send the miss down the diff --git a/internal/query/engine_layer_bundle_test.go b/internal/query/engine_layer_bundle_test.go new file mode 100644 index 000000000..ca0bed663 --- /dev/null +++ b/internal/query/engine_layer_bundle_test.go @@ -0,0 +1,106 @@ +package query + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/search/rerank" +) + +type bundleHydrationReader struct { + graph.Reader + layer *graph.OverlayLayer + hydrated []string +} + +func (r *bundleHydrationReader) Layer() *graph.OverlayLayer { return r.layer } +func (r *bundleHydrationReader) GetNodesByIDs(ids []string) map[string]*graph.Node { + r.hydrated = append(r.hydrated, ids...) + return r.Reader.GetNodesByIDs(ids) +} + +func TestLayerBundleHydrationKeepsUntouchedPayloadAndEdges(t *testing.T) { + base, backend, layer, replacement := overlayBundleFixture(t) + untouched := &graph.Node{ID: "repo/untouched.go::WidgetOther", Name: "WidgetOther", Kind: graph.KindFunction, FilePath: "repo/untouched.go"} + base.AddNode(untouched) + backend.bundles = append(backend.bundles, search.SymbolBundle{Node: untouched, Score: 8}) + reader := &bundleHydrationReader{Reader: graph.NewOverlaidView(base, layer), layer: layer} + engine := NewEngine(base) + engine.SetSearch(backend) + rctx := &rerank.Context{Graph: reader} + got := engine.WithReader(reader).GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, rctx) + require.Same(t, replacement, candidateByID(got, overlayBundleKeptID).Node) + require.Nil(t, candidateByID(got, overlayBundleGoneID)) + require.Same(t, untouched, candidateByID(got, untouched.ID).Node) + require.False(t, slices.Contains(reader.hydrated, untouched.ID), "an unrelated pending file must not discard this base bundle payload") + require.Equal(t, 1.0, rctx.EdgeCacheHitRate([]string{untouched.ID}), "safe base adjacency must remain seeded even when another hit needs rehydration") + require.Equal(t, 0.0, rctx.EdgeCacheHitRate([]string{overlayBundleKeptID}), "covered adjacency cannot be seeded from a stale base bundle") + require.False(t, rctx.CachePreSeeded(), "partial seeds cannot claim complete coverage") +} + +// The runtime contract wrapper deliberately hides the concrete Layer method. +// The explicit layer input must survive that wrapper, without type guessing. +type bundleContractReader struct{ graph.Reader } + +func TestLayerBundleContractWrapperKeepsPendingReplacement(t *testing.T) { + base, backend, layer, replacement := overlayBundleFixture(t) + reader := bundleContractReader{Reader: graph.NewOverlaidView(base, layer)} + engine := NewEngine(base) + engine.SetSearch(backend) + bound := engine.WithReader(reader) + if explicit, ok := any(bound).(interface { + WithLayers([]graph.OverlayLayerReader) *Engine + }); ok { + layers := []graph.OverlayLayerReader{layer} + bound = explicit.WithLayers(layers) + layers[0] = graph.NewOverlayLayer() // Caller slice mutation cannot rewrite an already bound request. + } + got := bound.GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, &rerank.Context{Graph: reader}) + require.Nil(t, candidateByID(got, overlayBundleGoneID), "contract wrapping must not revive a deleted base hit") + kept := candidateByID(got, overlayBundleKeptID) + require.NotNil(t, kept) + require.Same(t, replacement, kept.Node, "contract wrapping must not serve the pre-edit bundle row") +} + +func TestLayerBundleDoesNotSeedAdjacencyAffectedThroughAnotherFile(t *testing.T) { + for _, kind := range []string{"new_edge", "deleted_endpoint", "changed_edge_owner"} { + t.Run(kind, func(t *testing.T) { + base, backend, layer, _ := overlayBundleFixture(t) + node := &graph.Node{ID: "repo/untouched.go::WidgetOther", Name: "WidgetOther", Kind: graph.KindFunction, FilePath: "repo/untouched.go"} + base.AddNode(node) + bundle := search.SymbolBundle{Node: node, Score: 8} + if kind == "new_edge" { + layer.AddEdge(&graph.Edge{From: overlayBundleKeptID, To: node.ID, Kind: graph.EdgeCalls, FilePath: overlayBundleFile}) + } else { + target, file := overlayBundleGoneID, node.FilePath + if kind == "changed_edge_owner" { + target = overlayBundleCaller + file = overlayBundleFile + } + edge := &graph.Edge{From: node.ID, To: target, Kind: graph.EdgeCalls, FilePath: file} + base.AddEdge(edge) + bundle.OutEdges = []*graph.Edge{edge} + } + backend.bundles = append(backend.bundles, bundle) + view := graph.NewOverlaidView(base, layer) + reader := &bundleHydrationReader{Reader: view, layer: layer} + engine := NewEngine(base) + engine.SetSearch(backend) + rctx := &rerank.Context{Graph: reader} + got := engine.WithReader(reader).GatherSymbolCandidates("Widget", 5, QueryOptions{SkipInnerRerank: true, SkipVectorChannel: true}, rctx) + kept := candidateByID(got, node.ID) + require.NotNil(t, kept) + require.Same(t, node, kept.Node, "adjacency changes do not require refetching an unchanged node") + require.False(t, slices.Contains(reader.hydrated, node.ID)) + require.Zero(t, rctx.EdgeCacheHitRate([]string{node.ID}), "new/deleted/re-owned edges cannot be seeded from base bundles") + if kind == "new_edge" { + require.Len(t, view.GetInEdges(node.ID), 1) + } else { + require.Empty(t, view.GetOutEdges(node.ID)) + } + }) + } +} diff --git a/internal/query/engine_layers.go b/internal/query/engine_layers.go new file mode 100644 index 000000000..c99f24bfa --- /dev/null +++ b/internal/query/engine_layers.go @@ -0,0 +1,57 @@ +package query + +import ( + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" +) + +// WithLayers binds the ownership claims of the reader already selected on +// this clone. Reader wrappers need not expose concrete overlay implementations. +// The slice is copied; callers retain ownership of their slice storage. +func (e *Engine) WithLayers(layers []graph.OverlayLayerReader) *Engine { + if e == nil { + return nil + } + clone := *e + clone.layers = append([]graph.OverlayLayerReader(nil), layers...) + return &clone +} + +func (e *Engine) bundleNodeNeedsReader(node *graph.Node) bool { + if e.viewLayersActive() && len(e.layers) == 0 { + return true + } + for _, layer := range e.layers { + if layer != nil && (layer.HasFile(node.FilePath) || layer.CoversNodeID(node.ID) || layer.OwnsNodeIdentity(node.ID)) { + return true + } + } + return false +} + +func (e *Engine) bundleEdgesNeedReader(bundle search.SymbolBundle) bool { + node := bundle.Node + if e.bundleNodeNeedsReader(node) { + return true + } + for _, layer := range e.layers { + if layer == nil { + continue + } + // Base bundles cannot carry adjacency introduced by an upper layer. + if layer.OwnsOutEdges(node.ID) || len(layer.OutEdges(node.ID)) > 0 || len(layer.InEdges(node.ID)) > 0 { + return true + } + for _, edges := range [][]*graph.Edge{bundle.InEdges, bundle.OutEdges} { + for _, edge := range edges { + if edge == nil { + continue + } + if layer.HasFile(edge.FilePath) || layer.CoversNodeID(edge.From) || layer.CoversNodeID(edge.To) || layer.OwnsNodeIdentity(edge.From) || layer.OwnsNodeIdentity(edge.To) || layer.OwnsOutEdges(edge.From) { + return true + } + } + } + } + return false +} diff --git a/internal/query/view_candidates.go b/internal/query/view_candidates.go index 48f041603..5c163d58f 100644 --- a/internal/query/view_candidates.go +++ b/internal/query/view_candidates.go @@ -43,6 +43,11 @@ func (e *Engine) WithViewLayers(r graph.Reader, layers []ViewLayerSource) *Engin return clone } clone.viewLayers = layers + for _, source := range layers { + if source.Layer != nil { + clone.layers = append(clone.layers, source.Layer) + } + } return clone } From 5059b14a26ded30a41e492d4227bd41700a69692 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:01:43 +0100 Subject: [PATCH 062/138] test: cover CLI rejection of structured file drift --- cmd/gortex/cli_freshness_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cmd/gortex/cli_freshness_test.go b/cmd/gortex/cli_freshness_test.go index dbf9b81fe..caad29af7 100644 --- a/cmd/gortex/cli_freshness_test.go +++ b/cmd/gortex/cli_freshness_test.go @@ -37,3 +37,29 @@ func TestCLIRequireFreshPrintsLineAndExits(t *testing.T) { require.Equal(t, "relations: failed (1 files)\n", stderr.String()) require.Equal(t, true, applyCLIRequireFresh(nil)["require_fresh"]) } + +func TestCLIRequireFreshHonorsStructuredFileDrift(t *testing.T) { + prior := cliRequireFresh + cliRequireFresh = true + t.Cleanup(func() { cliRequireFresh = prior }) + for name, test := range map[string]struct { + body string + line string + }{ + "stale": {`{"freshness":{"stale":true}}`, "file: pending (1 files)\n"}, + "missing": {`{"freshness":{"missing":true}}`, "file: pending (1 files)\n"}, + "frozen": {`{"freshness":{"index_frozen":true}}`, "file: pending (1 files)\n"}, + "list": {`{"freshness":{"stale_files":[{"file":"a.go"}],"missing_files":[{"file":"b.go"}]}}`, "file: pending (2 files)\n"}, + } { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + err := checkCLIFreshness(cmd, []byte(test.body), nil) + var exit *exitCodeError + require.ErrorAs(t, err, &exit) + require.Equal(t, 4, exit.ExitCode()) + require.Equal(t, test.line, stderr.String()) + }) + } +} From e7480341c5002540c352b465bedcef04f602092b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:01:55 +0100 Subject: [PATCH 063/138] fix: enforce CLI freshness for structured file drift --- cmd/gortex/cli_freshness.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/gortex/cli_freshness.go b/cmd/gortex/cli_freshness.go index baf837b1b..cea2085f2 100644 --- a/cmd/gortex/cli_freshness.go +++ b/cmd/gortex/cli_freshness.go @@ -28,6 +28,15 @@ func checkCLIFreshness(cmd *cobra.Command, raw json.RawMessage, callErr error) e } state, _ := fields["graph_freshness"].(string) scope, _ := fields["freshness_scope"].(string) + if state == "" { + stale, _ := fields["stale_files"].([]any) + missing, _ := fields["missing_files"].([]any) + count := len(stale) + len(missing) + if count > 0 || fields["stale"] == true || fields["missing"] == true || fields["index_frozen"] == true { + state, scope = "pending", "file" + fields["pending_file_count"] = max(1, count) + } + } line := "" if state != "" && state != "fresh" { if scope == "" { From a25814b62c4a52acf9567d2bd4b7b8f55f60a610 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:02:44 +0100 Subject: [PATCH 064/138] bench: run foreign impact during a full active edit window --- bench/load/foreign_impact.go | 11 +- bench/load/foreign_impact_command.go | 98 +++++++++++ bench/load/foreign_impact_driver.go | 210 +++++++++++++++++++++++ bench/load/foreign_impact_driver_test.go | 71 ++++++++ bench/load/main.go | 5 +- 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 bench/load/foreign_impact_command.go create mode 100644 bench/load/foreign_impact_driver.go create mode 100644 bench/load/foreign_impact_driver_test.go diff --git a/bench/load/foreign_impact.go b/bench/load/foreign_impact.go index 426b0f1b7..0fc33beb4 100644 --- a/bench/load/foreign_impact.go +++ b/bench/load/foreign_impact.go @@ -27,6 +27,7 @@ type foreignImpactSample struct { EditedRepo string `json:"edited_repo"` TargetSymbol string `json:"target_symbol"` RPCID int64 `json:"rpc_id"` + QueryCWD string `json:"cwd,omitempty"` Started time.Time `json:"started"` Finished time.Time `json:"finished"` WithinWindow bool `json:"within_window"` @@ -45,7 +46,11 @@ type foreignImpactSample struct { func observeForeignImpact(ctx context.Context, client *socketClient, target, edited privateRepo, window foreignImpactWindow) foreignImpactSample { symbol := target.Prefix + "/" + target.Corpus.Symbol invalid := func(message string) foreignImpactSample { - return foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, nil, wireCallIdentity{}, errors.New(message)) + row := foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, nil, wireCallIdentity{}, errors.New(message)) + if client != nil { + row.QueryCWD = client.cwd + } + return row } if client == nil || client.cwd == "" || target.Path == "" || filepath.Clean(client.cwd) != filepath.Clean(target.Path) { return invalid("foreign impact requires its target checkout's own query session") @@ -58,7 +63,9 @@ func observeForeignImpact(ctx context.Context, client *socketClient, target, edi } args := map[string]any{"operation": "impact", "target": map[string]any{"symbol": symbol}, "output": map[string]any{"format": "json", "max_bytes": 0}} raw, identity, err := client.callWithIdentity(ctx, "tools/call", map[string]any{"name": "change", "arguments": args}) - return foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, raw, identity, err) + row := foreignImpactObservation(window, target.Corpus.Label, edited.Corpus.Label, symbol, raw, identity, err) + row.QueryCWD = client.cwd + return row } func foreignImpactObservation(window foreignImpactWindow, targetRepo, editedRepo, symbol string, raw json.RawMessage, identity wireCallIdentity, callErr error) foreignImpactSample { diff --git a/bench/load/foreign_impact_command.go b/bench/load/foreign_impact_command.go new file mode 100644 index 000000000..65bde5750 --- /dev/null +++ b/bench/load/foreign_impact_command.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "time" +) + +func parseForeignImpact(args []string) (runOptions, int, error) { + var o runOptions + f := flag.NewFlagSet("load foreign-impact", flag.ContinueOnError) + bindRunFlags(f, &o) + edited := f.Int("edited-repo", 0, "zero-based edited repository in the pinned six-root manifest") + if err := f.Parse(args); err != nil { + return o, *edited, err + } + if f.NArg() != 0 { + return o, *edited, errors.New("unexpected foreign-impact positional arguments") + } + if err := validateRunOptions(o); err != nil { + return o, *edited, err + } + if o.Scenario != "many" || o.Agents != 6 || o.Editors != 1 || o.Worktrees != 0 || o.EditRepos != 1 || o.EditCount != 100 || o.Seed != 42 || o.Profile != "agent" || o.CPUProfile != 0 || o.PRSmokeDrain != 0 || o.Duration != foreignImpactDuration || o.Deadline != 120*time.Second || o.WarmTimeout != 30*time.Minute || o.StopTimeout != 30*time.Second || *edited < 0 || *edited >= 6 { + return o, *edited, errors.New("foreign-impact requires many/6 sessions/1 editor/0 worktrees, seed42,100 edits, agent profile, duration10m, deadline120s, warm30m, stop30s and edited-repo0..5") + } + return o, *edited, nil +} + +// The separate entry retains the ordinary run lifecycle and its host checks. +// It never lengthens or replaces the main six-editor 100-offer experiment. +func foreignImpactCommand(ctx context.Context, args []string) error { + o, edited, err := parseForeignImpact(args) + if err != nil { + return err + } + corpora, err := loadManifest(o.Manifest) + if err != nil { + return err + } + if len(corpora) != 6 { + return errors.New("foreign-impact requires six distinct pinned repositories") + } + if err := validateOutputRoots(corpora, o.Scratch, o.Output); err != nil { + return err + } + sha, err := binaryRevision(o.Binary, o.GitSHA) + if err != nil { + return err + } + if err := os.MkdirAll(o.Scratch, 0o700); err != nil { + return err + } + host, group, err := inspectHost(o.Host, o.Scratch) + if err != nil { + return err + } + root, err := os.MkdirTemp(o.Scratch, "foreign-impact-report-") + if err != nil { + return err + } + if o.Output == "" { + o.Output = filepath.Join(root, "run.json") + } + if err := validateEditProbeSourceRepos(ctx, corpora, root); err != nil { + return err + } + report, err := newRunReport(o, corpora, sha, host, root) + if err != nil { + return err + } + foreignPath := filepath.Join(root, "foreign-impact-summary.json") + o.pairedDriver = func(ctx context.Context, p *privateRun, clients, _ []*socketClient, phase *Phase) error { + cell, runErr := runForeignImpactWithEdits(ctx, o, p, clients, phase, edited) + data, encodeErr := json.MarshalIndent(cell, "", " ") + if encodeErr == nil { + encodeErr = os.WriteFile(foreignPath, append(data, '\n'), 0o600) + } + return errors.Join(runErr, encodeErr) + } + phase, sweep, artifacts, crashes, version, runErr := runPhase(ctx, o, "many", corpora, group, root) + artifacts["foreign_impact_summary"] = foreignPath + addPhase(&report, phase, sweep, artifacts, crashes, version, runErr) + if err := finishReport(&report, 1); err != nil { + return err + } + if err := writeReport(o.Output, report); err != nil { + return err + } + if !report.Complete { + return fmt.Errorf("incomplete foreign-impact run; evidence retained in %s", o.Output) + } + return nil +} diff --git a/bench/load/foreign_impact_driver.go b/bench/load/foreign_impact_driver.go new file mode 100644 index 000000000..2e7b764be --- /dev/null +++ b/bench/load/foreign_impact_driver.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +type foreignImpactRunReport struct { + EditedRepo string `json:"edited_repo"` + TargetRepos []string `json:"target_repos"` + PlanHash string `json:"edit_plan_sha256"` + EditOffers int `json:"edit_offers"` + EditMeanNS int64 `json:"edit_mean_ns"` + FirstDueNS int64 `json:"first_edit_due_ns"` + LastDueNS int64 `json:"last_edit_due_ns"` + FirstEditWrite time.Time `json:"first_edit_write"` + Window foreignImpactWindow `json:"window"` + ProbePeriodNS int64 `json:"probe_period_ns"` + ProbeStarted time.Time `json:"probe_started"` + ProbeFinished time.Time `json:"probe_finished"` + Summary foreignImpactSummary `json:"summary"` + RawFile string `json:"raw_file"` + Complete bool `json:"complete"` +} + +// The foreign cell keeps the accepted one-editor Poisson schedule. Unlike the +// six-editor 100-global-offer cell, this stream must span the full ten minutes. +func foreignImpactEditPlan(c Corpus, seed int64) (editProbePlan, error) { + plan, err := planEditProbe(c, runOptions{Seed: seed, EditCount: 100, Editors: 1}) + if err != nil { + return plan, err + } + if len(plan.Steps) != 100 || plan.MeanNS != int64(editProbeMean) || plan.Steps[len(plan.Steps)-1].DueNS-plan.Steps[0].DueNS < int64(foreignImpactDuration) { + return plan, errors.New("fixed foreign edit plan does not cover ten active minutes; never fill the remainder with idle queries") + } + return plan, nil +} + +func foreignImpactQueryTargets(repos []privateRepo, clients []*socketClient, edited int) ([]foreignImpactTarget, error) { + if len(repos) != 6 || edited < 0 || edited >= len(repos) { + return nil, errors.New("foreign impact requires the six pinned repositories and one selected editor") + } + var targets []foreignImpactTarget + for i, repo := range repos { + if i == edited { + continue + } + var owner *socketClient + for _, client := range clients { + if client != nil && filepath.Clean(client.cwd) == filepath.Clean(repo.Path) { + if owner != nil { + return nil, errors.New("ambiguous target query session") + } + owner = client + } + } + if owner == nil { + return nil, fmt.Errorf("no own query session for %s", repo.Corpus.Label) + } + targets = append(targets, foreignImpactTarget{Client: owner, Repo: repo}) + } + return targets, nil +} + +// runForeignImpactWithEdits is called from the existing owned runPhase driver +// hook after all six roots pass readiness. It creates no process, store, source +// clone, or new traffic scheduler. The normal lifecycle still owns shutdown. +func runForeignImpactWithEdits(ctx context.Context, o runOptions, p *privateRun, clients []*socketClient, phase *Phase, editedOrdinal int) (report foreignImpactRunReport, resultErr error) { + targets, err := foreignImpactQueryTargets(p.Repos, clients, editedOrdinal) + if err != nil { + return report, err + } + edited := p.Repos[editedOrdinal] + plan, err := foreignImpactEditPlan(edited.Corpus, o.Seed) + if err != nil { + return report, err + } + if phase.editProbeRuntime != nil { + return report, errors.New("foreign driver cannot replace an admitted edit plan") + } + report.EditedRepo = edited.Corpus.Label + report.EditOffers = len(plan.Steps) + report.EditMeanNS = plan.MeanNS + report.FirstDueNS = plan.Steps[0].DueNS + report.LastDueNS = plan.Steps[len(plan.Steps)-1].DueNS + report.ProbePeriodNS = int64(editProbePeriod) + report.PlanHash, _ = hashJSON(plan) + for _, target := range targets { + report.TargetRepos = append(report.TargetRepos, target.Repo.Corpus.Label) + } + editor, err := openMCP(ctx, p.Socket, edited.Path) + if err != nil { + return report, err + } + defer editor.Close() + // All roots stay registered in the daemon. Only B owns offered edits. + one := &privateRun{Root: p.Root, SocketDir: p.SocketDir, Socket: p.Socket, Store: p.Store, Config: p.Config, Env: p.Env, Repos: []privateRepo{edited}, Binary: p.Binary} + options := o + options.Agents = 0 + options.Editors = 1 + options.EditRepos = 1 + options.Worktrees = 0 + options.EditCount = 100 + options.pairedDriver = nil + runtime, probers, err := prepareEditProbe(ctx, options, one, nil, []*socketClient{editor}, phase) + defer func() { + for _, client := range probers { + _ = client.Close() + } + if runtime != nil { + resultErr = errors.Join(resultErr, runtime.close()) + } + phase.editProbeRuntime = nil + }() + if err != nil { + return report, err + } + phase.SessionModel = "one_B_editor_and_five_independent_rotating_foreign_queries" + phase.Metrics["mcp_sessions"] = measured(float64(len(clients)+len(probers)+1), "sessions") + rawPath := filepath.Join(p.Root, "foreign-impact.jsonl") + raw, err := os.OpenFile(rawPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return report, err + } + defer func() { resultErr = errors.Join(resultErr, raw.Close()) }() + report.RawFile = rawPath + budget, err := plan.budget(o.Deadline, 1) + if err != nil { + return report, err + } + measuredCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + captureReceipt(ctx, one, editor, phase) + phase.EditProbe.StatsBefore, err = runtime.stats(ctx) + if err != nil { + return report, err + } + phase.Started = time.Now() + runtime.origin = phase.Started + done := make(chan error, 1) + go func() { + scheduleErr := runEditProbeArrivals(measuredCtx, runtime.plan, runtime.origin, runtime.activate, runtime.doEdit, waitContext) + done <- errors.Join(scheduleErr, runtime.finish()) + }() + // The first actual edit write is already retained by the existing RPC + // observer. Wait for that evidence, rather than assuming its planned due time. + var editErr error + editsJoined := false + ticker := time.NewTicker(editProbePeriod) +awaitWrite: + for report.FirstEditWrite.IsZero() && measuredCtx.Err() == nil { + first := runtime.states[0] + first.mu.Lock() + report.FirstEditWrite = first.editWrite + first.mu.Unlock() + if !report.FirstEditWrite.IsZero() { + break + } + select { + case <-ticker.C: + case <-measuredCtx.Done(): + case editErr = <-done: + editsJoined = true + break awaitWrite + } + } + ticker.Stop() + if report.FirstEditWrite.IsZero() { + cancel() + resultErr = errors.New("no actual B edit write was observed") + } else { + report.Window = foreignImpactWindow{ID: "foreign-" + edited.Corpus.Label, EditedRepo: edited.Corpus.Label, Start: time.Now()} + report.Window.End = report.Window.Start.Add(foreignImpactDuration) + if runtime.origin.Add(time.Duration(report.LastDueNS)).Before(report.Window.End) { + cancel() + resultErr = errors.New("actual edit start was too late to cover the full foreign window") + } else { + report.ProbeStarted = time.Now() + report.Summary, err = runForeignImpactStream(measuredCtx, targets, edited, report.Window, func(row foreignImpactSample) error { return json.NewEncoder(raw).Encode(row) }) + report.ProbeFinished = time.Now() + resultErr = errors.Join(resultErr, err) + if err != nil { + cancel() + } + } + } + if !editsJoined { + editErr = <-done + } + resultErr = errors.Join(resultErr, editErr) + phase.EditProbe.StatsAfter, err = runtime.stats(ctx) + resultErr = errors.Join(resultErr, err) + if ctx.Err() == nil { + restoreCtx, stop := context.WithTimeout(ctx, o.Deadline) + resultErr = errors.Join(resultErr, runtime.restore(restoreCtx)) + stop() + } else { + resultErr = errors.Join(resultErr, errors.New("foreign-cell original restore unavailable after parent cancellation")) + } + report.Complete = resultErr == nil && !report.ProbeFinished.Before(report.Window.End) && report.Summary.Offered > 0 + phase.Metrics["foreign_impact_p95_ms"] = report.Summary.Latency.P95 + phase.Metrics["foreign_impact_refusals"] = measured(float64(report.Summary.Refusals), "calls") + phase.Metrics["foreign_impact_errors"] = measured(float64(report.Summary.Errors), "calls") + return report, resultErr +} diff --git a/bench/load/foreign_impact_driver_test.go b/bench/load/foreign_impact_driver_test.go new file mode 100644 index 000000000..6cddacc5e --- /dev/null +++ b/bench/load/foreign_impact_driver_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestForeignImpactEditPlanKeepsAcceptedOneEditorTrafficAndCoversWindow(t *testing.T) { + c := editProbeCorpus() + original, err := makeEditProbePlan(c, 42, 100) + if err != nil { + t.Fatal(err) + } + plan, err := foreignImpactEditPlan(c, 42) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(plan, original) { + t.Fatal("foreign window changed the fixed editor's seeded requests/arrivals") + } + if len(plan.Steps) != 100 || plan.MeanNS != int64(15*time.Second) || plan.Steps[99].DueNS-plan.Steps[0].DueNS < int64(10*time.Minute) { + t.Fatalf("fixed edit traffic cannot support required active window: %+v", plan) + } + six, err := makeEditProbeTrafficPlan(c, 42, 100, 6) + if err != nil { + t.Fatal(err) + } + if six.Steps[99].DueNS-six.Steps[0].DueNS >= int64(foreignImpactDuration) { + t.Fatal("control no longer demonstrates why the short six-editor stream cannot be padded with idle probes") + } +} + +func TestForeignImpactQueryTargetsRequireEachForeignCheckoutOwnSession(t *testing.T) { + root := t.TempDir() + var repos []privateRepo + var clients []*socketClient + for _, name := range []string{"one", "two", "three", "four", "five", "six"} { + repo := privateRepo{Path: filepath.Join(root, name), Prefix: name, Corpus: Corpus{Label: name}} + repos = append(repos, repo) + clients = append(clients, &socketClient{cwd: repo.Path}) + } + targets, err := foreignImpactQueryTargets(repos, clients, 2) + if err != nil || len(targets) != 5 { + t.Fatalf("six-root target selection failed: %v %+v", err, targets) + } + for _, target := range targets { + if target.Repo.Corpus.Label == "three" || target.Client.cwd != target.Repo.Path { + t.Fatal("edited/foreign checkout was substituted") + } + } + if _, err := foreignImpactQueryTargets(repos, append(clients, clients[0]), 2); err == nil { + t.Fatal("ambiguous duplicate query session accepted") + } + if _, err := foreignImpactQueryTargets(repos, clients[:5], 2); err == nil { + t.Fatal("missing target session fell back to primary") + } +} + +func TestForeignImpactCommandRejectsChangedTrafficAndBudgets(t *testing.T) { + args := []string{"--binary", "/owned/product", "--manifest", "/owned/manifest", "--scratch", "/owned/runs", "--scenario", "many", "--agents", "6", "--edit-agents", "1", "--worktrees", "0", "--seed", "42", "--duration", "10m"} + if _, _, err := parseForeignImpact(args); err != nil { + t.Fatal(err) + } + for _, changed := range [][]string{{"--seed", "43"}, {"--edit-count", "1"}, {"--duration", "9m"}, {"--agents", "7"}, {"--deadline", "119s"}, {"--edited-repo", "6"}} { + if _, _, err := parseForeignImpact(append(append([]string(nil), args...), changed...)); err == nil { + t.Fatalf("accepted changed foreign cell %v", changed) + } + } +} diff --git a/bench/load/main.go b/bench/load/main.go index d992f943e..f88414d1d 100644 --- a/bench/load/main.go +++ b/bench/load/main.go @@ -17,9 +17,12 @@ func main() { } func command(args []string) error { - if len(args) > 0 && (args[0] == "run" || args[0] == "paired" || args[0] == "smoke") { + if len(args) > 0 && (args[0] == "run" || args[0] == "paired" || args[0] == "smoke" || args[0] == "foreign-impact") { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + if args[0] == "foreign-impact" { + return foreignImpactCommand(ctx, args[1:]) + } if args[0] == "paired" { return pairedCommand(ctx, args[1:]) } From f24e57424d5932b4f744afe3fc33fe308f7d9ec0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:05:05 +0100 Subject: [PATCH 065/138] feat(bench): declare complete edit-loop gate matrix --- bench/budgets.yaml | 107 ++++++++++++++------- bench/check-budgets/edit_loop_rows_test.go | 42 +++++++- bench/check-budgets/policy.go | 9 +- bench/check-budgets/specs.go | 1 + 4 files changed, 119 insertions(+), 40 deletions(-) diff --git a/bench/budgets.yaml b/bench/budgets.yaml index 4decda144..88b214d6c 100644 --- a/bench/budgets.yaml +++ b/bench/budgets.yaml @@ -11,73 +11,73 @@ allowances: - {"name": "dispatch_queue_memory", "owning_release": "v0.64.11", "kind": "additive", "value": 64, "unit": "MiB", "metrics": ["peak_memory_mib", "idle_memory_2s_mib", "worktree_memory_mib"], "scenarios": ["load-1", "load-8", "load-16", "load-32", "loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "worktrees-4", "worktrees-8", "worktrees-16", "cold-gortex", "cold-six"]} budgets: # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.15, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.15, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1.5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.2, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.2, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 3, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.4, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.25, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.4, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.15", "mode": "guard", "target": 15, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - {"metric": "relations_lag_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "derived_lag_p95", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.55, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.35, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.55, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "unassigned", "mode": "guard", "target": null, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.5, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.75, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.5, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.75, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} - {"metric": "edit_written_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "unassigned", "mode": "guard", "target": null, "unit": "MiB/edit", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / block - - {"metric": "edit_ack_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.3, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "edit_ack_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "readback_correct", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release"], "compare": "ge"} + - {"metric": "edit_ack_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.3, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "edit_ack_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "readback_correct", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.999, "unit": "fraction", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "ge"} - {"metric": "unflagged_stale", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} - - {"metric": "reclaim_hold_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} - - {"metric": "read_gate_closures", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release"], "compare": "eq"} + - {"metric": "reclaim_hold_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.05, "unit": "s", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "le"} + - {"metric": "read_gate_closures", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["nightly", "release", "edit-smoke"], "compare": "eq"} - {"metric": "relations_lag_p95", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 9, "unit": "s", "allowance": [], "lane": ["nightly", "release"], "compare": "le"} # reference_linux / guard - {"metric": "wal_peak_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.17", "mode": "guard", "target": 2048, "unit": "MiB", "allowance": ["wal_reclaim_yield"], "lane": ["nightly", "release"], "compare": "le"} @@ -907,3 +907,40 @@ budgets: - {"metric": "wal_p95_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 760, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} - {"metric": "edit_ack_p95", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0.6, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} - {"metric": "edit_ack_max", "scenario": "loadedit-4-largefile", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "readback_max", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-1", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-4", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "readback_max", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-8", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-16", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "loadedit-32", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "readback_max", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 1, "unit": "s", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "wal_peak_mib", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 2048, "unit": "MiB", "allowance": [], "lane": ["edit-smoke"], "compare": "le"} + - {"metric": "silent_stale_json", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_toon", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_compact", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_facade", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} + - {"metric": "silent_stale_cli", "scenario": "multirepoedit-6", "host_class": "reference_linux", "owning_release": "v0.64.12", "mode": "block", "target": 0, "unit": "count", "allowance": [], "lane": ["edit-smoke"], "compare": "eq"} diff --git a/bench/check-budgets/edit_loop_rows_test.go b/bench/check-budgets/edit_loop_rows_test.go index 4e383935f..6d83ca338 100644 --- a/bench/check-budgets/edit_loop_rows_test.go +++ b/bench/check-budgets/edit_loop_rows_test.go @@ -28,9 +28,28 @@ func TestBudgetsDeclareEveryEditLoopRow(t *testing.T) { } t.Errorf("missing absolute edit-smoke row %s/%s", scenario, metric) } - for _, mode := range []string{"json", "toon", "compact", "facade", "cli"} { - requireRow("loadedit-4", "silent_stale_"+mode, 0, "count", "eq") + for _, cell := range []struct { + scenario string + ack, read float64 + }{ + {"loadedit-1", .15, .25}, {"loadedit-4", .20, .35}, {"loadedit-8", .25, .40}, + {"loadedit-16", .35, .55}, {"loadedit-32", .50, .75}, {"multirepoedit-6", .30, 0}, + } { + requireRow(cell.scenario, "edit_ack_p95", cell.ack, "s", "le") + requireRow(cell.scenario, "edit_ack_max", 1, "s", "le") + if cell.read > 0 { + requireRow(cell.scenario, "readback_p95", cell.read, "s", "le") + } + requireRow(cell.scenario, "readback_max", 1, "s", "le") + requireRow(cell.scenario, "readback_correct", .999, "fraction", "ge") + requireRow(cell.scenario, "reclaim_hold_p95", .05, "s", "le") + requireRow(cell.scenario, "read_gate_closures", 0, "count", "eq") + requireRow(cell.scenario, "wal_peak_mib", 2048, "MiB", "le") + for _, mode := range []string{"json", "toon", "compact", "facade", "cli"} { + requireRow(cell.scenario, "silent_stale_"+mode, 0, "count", "eq") + } } + for _, cell := range []struct { scenario string walP95 float64 @@ -42,3 +61,22 @@ func TestBudgetsDeclareEveryEditLoopRow(t *testing.T) { requireRow("loadedit-4-largefile", "edit_ack_p95", 0.6, "s", "le") requireRow("loadedit-4-largefile", "edit_ack_max", 1, "s", "le") } + +func TestBudgetTargetsMayDifferOnlyAcrossDisjointLanes(t *testing.T) { + p, err := loadPolicy([]byte(policyFixture)) + if err != nil { + t.Fatal(err) + } + additional := p.Budgets[0] + threshold := 0.1 + additional.Target = &threshold + additional.Lane = []string{"edit-smoke"} + p.Budgets = append(p.Budgets, additional) + if err := p.validate(); err != nil { + t.Fatalf("separate edit-probe lane cannot declare its own absolute target: %v", err) + } + p.Budgets[len(p.Budgets)-1].Lane = []string{"nightly"} + if err := p.validate(); err == nil { + t.Fatal("overlapping lane targets must remain invalid") + } +} diff --git a/bench/check-budgets/policy.go b/bench/check-budgets/policy.go index 21f0e252c..0d0b7f1dd 100644 --- a/bench/check-budgets/policy.go +++ b/bench/check-budgets/policy.go @@ -151,10 +151,13 @@ func (p policy) validate() error { return fmt.Errorf("budget %d: %w", i+1, err) } key := b.HostClass + "/" + b.Scenario + "/" + b.Metric - if seen[key] { - return fmt.Errorf("duplicate budget %s", key) + for _, lane := range b.Lane { + laneKey := key + "/" + lane + if seen[laneKey] { + return fmt.Errorf("duplicate budget %s", laneKey) + } + seen[laneKey] = true } - seen[key] = true } return nil } diff --git a/bench/check-budgets/specs.go b/bench/check-budgets/specs.go index 4bb3225b9..db70cd5d0 100644 --- a/bench/check-budgets/specs.go +++ b/bench/check-budgets/specs.go @@ -11,6 +11,7 @@ type metricSpec struct { } var metricSpecs = map[string]metricSpec{ + "readback_max": {unit: "s", axis: "timing", everyRepeat: true, missing: "report-v1 does not retain exact edited-file readback latency"}, "wal_p95_mib": {unit: "MiB", axis: "disk", missing: "report-v1 does not retain a WAL observation distribution"}, "edit_ack_p95": {unit: "s", axis: "timing"}, "edit_ack_max": {unit: "s", axis: "timing", everyRepeat: true}, From 49525e1afc3d89ec22693678b8a6fbda92a9b114 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:05:22 +0100 Subject: [PATCH 066/138] bench: cap foreign-impact evidence and retain quota failures --- bench/load/foreign_impact_driver.go | 38 +++++++++++++++++++++++- bench/load/foreign_impact_driver_test.go | 22 ++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/bench/load/foreign_impact_driver.go b/bench/load/foreign_impact_driver.go index 2e7b764be..763150489 100644 --- a/bench/load/foreign_impact_driver.go +++ b/bench/load/foreign_impact_driver.go @@ -5,11 +5,37 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "time" ) +const foreignImpactRawLimit = int64(1 << 30) + +type foreignImpactEvidenceWriter struct { + w io.Writer + bytes int64 + limit int64 +} + +func (w *foreignImpactEvidenceWriter) emit(row foreignImpactSample) error { + data, err := json.Marshal(row) + if err != nil { + return err + } + data = append(data, '\n') + if w.limit <= 0 || int64(len(data)) > w.limit-w.bytes { + return errors.New("foreign impact raw evidence limit exhausted; execution incomplete") + } + n, err := w.w.Write(data) + w.bytes += int64(n) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return err +} + type foreignImpactRunReport struct { EditedRepo string `json:"edited_repo"` TargetRepos []string `json:"target_repos"` @@ -25,6 +51,8 @@ type foreignImpactRunReport struct { ProbeFinished time.Time `json:"probe_finished"` Summary foreignImpactSummary `json:"summary"` RawFile string `json:"raw_file"` + RawBytes int64 `json:"raw_bytes"` + RawLimit int64 `json:"raw_limit"` Complete bool `json:"complete"` } @@ -71,6 +99,11 @@ func foreignImpactQueryTargets(repos []privateRepo, clients []*socketClient, edi // hook after all six roots pass readiness. It creates no process, store, source // clone, or new traffic scheduler. The normal lifecycle still owns shutdown. func runForeignImpactWithEdits(ctx context.Context, o runOptions, p *privateRun, clients []*socketClient, phase *Phase, editedOrdinal int) (report foreignImpactRunReport, resultErr error) { + defer func() { + if resultErr != nil { + report.Complete = false + } + }() targets, err := foreignImpactQueryTargets(p.Repos, clients, editedOrdinal) if err != nil { return report, err @@ -129,6 +162,9 @@ func runForeignImpactWithEdits(ctx context.Context, o runOptions, p *privateRun, } defer func() { resultErr = errors.Join(resultErr, raw.Close()) }() report.RawFile = rawPath + report.RawLimit = foreignImpactRawLimit + evidence := foreignImpactEvidenceWriter{w: raw, limit: foreignImpactRawLimit} + defer func() { report.RawBytes = evidence.bytes }() budget, err := plan.budget(o.Deadline, 1) if err != nil { return report, err @@ -181,7 +217,7 @@ awaitWrite: resultErr = errors.New("actual edit start was too late to cover the full foreign window") } else { report.ProbeStarted = time.Now() - report.Summary, err = runForeignImpactStream(measuredCtx, targets, edited, report.Window, func(row foreignImpactSample) error { return json.NewEncoder(raw).Encode(row) }) + report.Summary, err = runForeignImpactStream(measuredCtx, targets, edited, report.Window, evidence.emit) report.ProbeFinished = time.Now() resultErr = errors.Join(resultErr, err) if err != nil { diff --git a/bench/load/foreign_impact_driver_test.go b/bench/load/foreign_impact_driver_test.go index 6cddacc5e..e45fa1c33 100644 --- a/bench/load/foreign_impact_driver_test.go +++ b/bench/load/foreign_impact_driver_test.go @@ -1,6 +1,8 @@ package main import ( + "bytes" + "encoding/json" "path/filepath" "reflect" "testing" @@ -69,3 +71,23 @@ func TestForeignImpactCommandRejectsChangedTrafficAndBudgets(t *testing.T) { } } } + +func TestForeignImpactEvidenceLimitRetainsPriorRowsAndStopsAtBoundary(t *testing.T) { + row := foreignImpactSample{TargetRepo: "A", EditedRepo: "B", RPCID: 9} + data, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + var dst bytes.Buffer + writer := foreignImpactEvidenceWriter{w: &dst, limit: int64(len(data) + 1)} + if err := writer.emit(row); err != nil { + t.Fatal(err) + } + before := append([]byte(nil), dst.Bytes()...) + if err := writer.emit(row); err == nil { + t.Fatal("exhausted raw evidence storage silently accepted another offer") + } + if !bytes.Equal(before, dst.Bytes()) || writer.bytes != int64(len(before)) { + t.Fatal("quota failure changed previously retained evidence") + } +} From 531976c91e1eb5868b7951f32211dbf32e5d647b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:19:02 +0100 Subject: [PATCH 067/138] test(bench): require gate mode coverage and unavailable rows --- bench/check-budgets/edit_loop_command_test.go | 28 ++++ bench/load/edit_gate_modes_test.go | 150 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 bench/check-budgets/edit_loop_command_test.go create mode 100644 bench/load/edit_gate_modes_test.go diff --git a/bench/check-budgets/edit_loop_command_test.go b/bench/check-budgets/edit_loop_command_test.go new file mode 100644 index 000000000..76ce2510c --- /dev/null +++ b/bench/check-budgets/edit_loop_command_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +func TestEditLoopGateRetainsEveryRowWhenEvidenceIsMissing(t *testing.T) { + var out, diagnostics bytes.Buffer + code := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(t.TempDir(), "absent-inputs.json")}, &out, &diagnostics) + if code != 1 { + t.Fatalf("missing gate evidence exit=%d, want1; diagnostics=%s", code, diagnostics.String()) + } + text := out.String() + for _, row := range []string{"edit_ack_p95", "edit_ack_max", "readback_p95", "readback_max", "readback_correct", "silent_stale_json", "silent_stale_toon", "silent_stale_compact", "silent_stale_facade", "silent_stale_cli", "wal_p95_mib", "wal_peak_mib", "reclaim_hold_p95", "read_gate_closures"} { + if !strings.Contains(text, row) { + t.Errorf("missing evidence hid mandatory row %s: %s", row, text) + } + } + if !strings.Contains(text, "UNAVAILABLE") || !strings.Contains(strings.ToLower(text), "baseline") || !strings.Contains(strings.ToLower(text), "delta") { + t.Errorf("gate lost unavailable/baseline/delta interpretation: %s", text) + } + if strings.Contains(text, "\tPASS\t") { + t.Fatal("unobserved row became a numeric pass") + } +} diff --git a/bench/load/edit_gate_modes_test.go b/bench/load/edit_gate_modes_test.go new file mode 100644 index 000000000..920ad2acd --- /dev/null +++ b/bench/load/edit_gate_modes_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestEditGateModesKeepFourEditorScheduleAndJSONACK(t *testing.T) { + args := []string{"--binary", "unused", "--manifest", "unused", "--scratch", t.TempDir(), "--scenario", editProbeScenario, "--agents", "0", "--edit-agents", "4", "--worktrees", "0"} + ordinary, err := parseRun(args) + if err != nil { + t.Fatal(err) + } + c := editProbeCorpus() + baseline, err := planEditProbe(c, ordinary) + if err != nil { + t.Fatal(err) + } + options, err := parseRun(append(args, "--edit-gate-modes")) + if err != nil { + t.Fatalf("gate-specific five-mode coverage cannot be planned: %v", err) + } + plan, err := planEditProbe(c, options) + if err != nil { + t.Fatal(err) + } + if plan.Count != 100 || plan.Editors != 4 || plan.Mode != "json" || len(plan.Steps) != 100 { + t.Fatalf("gate changed the ACK workload: %+v", plan) + } + data, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + var wire struct { + Steps []struct { + Mode string `json:"probe_mode"` + } `json:"steps"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + modes := []string{"json", "toon", "compact", "facade", "cli"} + counts := map[string]int{} + for i, step := range plan.Steps { + if got := wire.Steps[i].Mode; got != modes[i%len(modes)] { + t.Fatalf("ordinal%d planned mode%q, want%q", i, got, modes[i%len(modes)]) + } + counts[wire.Steps[i].Mode]++ + old := baseline.Steps[i] + if step.DueNS != old.DueNS || step.ActivateNS != old.ActivateNS || step.Editor != old.Editor || step.Marker != old.Marker || step.MutationID != old.MutationID || step.RequestHash != old.RequestHash || !reflect.DeepEqual(step.Request, old.Request) { + t.Fatalf("mode coverage changed offered edit/ACK at ordinal%d", i) + } + } + for _, mode := range modes { + if counts[mode] != 20 { + t.Fatalf("planned denominator %s=%d, want20 of100 total", mode, counts[mode]) + } + } + recordData, err := json.Marshal(newEditProbeReport(plan, c, true)) + if err != nil { + t.Fatal(err) + } + var report struct { + Records []struct { + Mode string `json:"probe_mode"` + } `json:"edits"` + } + if err := json.Unmarshal(recordData, &report); err != nil { + t.Fatal(err) + } + if len(report.Records) != 100 { + t.Fatal("censored setup lost offered edits") + } + for i, record := range report.Records { + if record.Mode != modes[i%len(modes)] { + t.Fatalf("censored ordinal%d lost explicit mode", i) + } + } +} + +func TestOrdinaryEditPlanHasNoGateModeAssignments(t *testing.T) { + c := editProbeCorpus() + old, err := makeEditProbeTrafficPlan(c, 42, 100, 4) + if err != nil { + t.Fatal(err) + } + got, err := planEditProbe(c, runOptions{Seed: 42, EditCount: 100, Editors: 4}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(old, got) { + t.Fatal("ordinary plan changed without gate opt-in") + } + data, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if _, exists := wire["gate_modes"]; exists { + t.Fatal("default hash gained gate metadata") + } +} + +func TestEditGateFacadeRequiresMatchingDispatchEnvelopeAndActualJSON(t *testing.T) { + makeWire := func(change string) json.RawMessage { + identity := map[string]any{"surface_version": "facade-v1", "facade": "read", "operation": "source", "canonical_tool": "get_symbol_source"} + wire := map[string]any{"content": []any{map[string]any{"type": "text", "text": "{\"source\":\"marker\"}"}}, "_meta": map[string]any{"gortex_facade": identity}} + switch change { + case "missing": + delete(wire, "_meta") + case "wrong_surface": + identity["surface_version"] = "legacy" + case "wrong_facade": + identity["facade"] = "search" + case "wrong_operation": + identity["operation"] = "summary" + case "wrong_canonical": + identity["canonical_tool"] = "read_file" + case "structured_only": + wire["content"] = []any{} + wire["structuredContent"] = map[string]any{"source": "marker"} + case "non_json": + wire["content"] = []any{map[string]any{"type": "text", "text": "source: marker"}} + wire["structuredContent"] = map[string]any{"source": "marker"} + } + data, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + return data + } + body, err := decodeEditProbeOutput(makeWire(""), "facade", "read.source") + if err != nil { + t.Fatalf("actual facade-v1 result cannot be observed: %v", err) + } + if body["source"] != "marker" { + t.Fatal("facade proof replaced actual payload semantics") + } + for _, change := range []string{"missing", "wrong_surface", "wrong_facade", "wrong_operation", "wrong_canonical", "structured_only", "non_json"} { + t.Run(change, func(t *testing.T) { + if _, err := decodeEditProbeOutput(makeWire(change), "facade", "read.source"); err == nil { + t.Fatal("dispatch or payload evidence was fabricated") + } + }) + } +} From 9ac6ed58124e8230414f69b0e6022f5d8aae8674 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:29:19 +0100 Subject: [PATCH 068/138] bench: describe foreign-query roles and preserve the full edit plan --- bench/load/foreign_impact_command.go | 36 ++++++++++++++++++- bench/load/foreign_impact_driver_test.go | 44 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/bench/load/foreign_impact_command.go b/bench/load/foreign_impact_command.go index 65bde5750..2b482f627 100644 --- a/bench/load/foreign_impact_command.go +++ b/bench/load/foreign_impact_command.go @@ -69,7 +69,7 @@ func foreignImpactCommand(ctx context.Context, args []string) error { if err := validateEditProbeSourceRepos(ctx, corpora, root); err != nil { return err } - report, err := newRunReport(o, corpora, sha, host, root) + report, err := newForeignImpactRunReport(o, corpora, edited, sha, host, root) if err != nil { return err } @@ -83,6 +83,9 @@ func foreignImpactCommand(ctx context.Context, args []string) error { return errors.Join(runErr, encodeErr) } phase, sweep, artifacts, crashes, version, runErr := runPhase(ctx, o, "many", corpora, group, root) + phase.Name = "foreign-impact" + phase.SessionModel = report.Workload.SessionModel + phase.Operations = report.Workload.Operations artifacts["foreign_impact_summary"] = foreignPath addPhase(&report, phase, sweep, artifacts, crashes, version, runErr) if err := finishReport(&report, 1); err != nil { @@ -96,3 +99,34 @@ func foreignImpactCommand(ctx context.Context, args []string) error { } return nil } + +// The ordinary many lifecycle is an implementation detail. The persisted load +// identity names the actual five foreign readers and complete B edit plan; +// duration is the query window, while Phase retains the full edit/drain time. +func newForeignImpactRunReport(o runOptions, corpora []Corpus, edited int, sha string, host Host, root string) (Report, error) { + if len(corpora) != 6 || edited < 0 || edited >= len(corpora) { + return Report{}, errors.New("foreign report requires six roots and an edited-repository ordinal") + } + report, err := newRunReport(o, corpora, sha, host, root) + if err != nil { + return report, err + } + plan, err := foreignImpactEditPlan(corpora[edited], o.Seed) + if err != nil { + return report, err + } + report.Workload.Scenario = "foreign-impact" + report.Workload.Agents = 5 + report.Workload.EditAgents = 1 + report.Workload.SessionModel = "one_B_editor_and_five_independent_rotating_foreign_queries" + report.Workload.EditProbe = &plan + report.Workload.Operations = editProbeOperations(plan, corpora[edited]) + for i, c := range corpora { + if i == edited { + continue + } + report.Workload.Operations = append(report.Workload.Operations, request{Tool: "change", Operation: "impact", Arguments: map[string]any{"operation": "impact", "target": map[string]any{"symbol": c.Label + "/" + c.Symbol}, "output": map[string]any{"format": "json", "max_bytes": 0}}}) + } + report.RequestPlan, err = hashJSON(report.Workload) + return report, err +} diff --git a/bench/load/foreign_impact_driver_test.go b/bench/load/foreign_impact_driver_test.go index e45fa1c33..8bfcbb793 100644 --- a/bench/load/foreign_impact_driver_test.go +++ b/bench/load/foreign_impact_driver_test.go @@ -91,3 +91,47 @@ func TestForeignImpactEvidenceLimitRetainsPriorRowsAndStopsAtBoundary(t *testing t.Fatal("quota failure changed previously retained evidence") } } + +func TestForeignImpactRunReportDescribesActualRolesAndPreservesEditPlan(t *testing.T) { + corpora := editProbeRoutingCorpora() + o := runOptions{Scenario: "many", Agents: 6, Editors: 1, Seed: 42, Duration: foreignImpactDuration, Deadline: 120 * time.Second, WarmTimeout: 30 * time.Minute, StopTimeout: 30 * time.Second, Profile: "agent", EditCount: 100} + report, err := newForeignImpactRunReport(o, corpora, 2, corpora[0].Commit, Host{}, "owned-report") + if err != nil { + t.Fatal(err) + } + if report.Workload.Scenario != "foreign-impact" || report.Workload.Agents != 5 || report.Workload.EditAgents != 1 || report.Workload.SessionModel != "one_B_editor_and_five_independent_rotating_foreign_queries" { + t.Fatalf("foreign workload mislabeled as ordinary read-only many cell: %+v", report.Workload) + } + if report.Workload.EditProbe == nil || report.Workload.EditProbe.Count != 100 || report.Workload.EditProbe.Editors != 1 || report.Workload.EditProbe.Repo != corpora[2].Label || report.Workload.DurationSec != 600 { + t.Fatal("active foreign window or full B edit schedule lost") + } + plan, err := foreignImpactEditPlan(corpora[2], 42) + if err != nil || !reflect.DeepEqual(*report.Workload.EditProbe, plan) { + t.Fatal("outer report differs from actual seeded B editor plan") + } + if len(report.Workload.Operations) < 5 || report.Workload.Operations[0].name() != "edit.file" { + t.Fatal("outer report omitted offered edits") + } + for i, op := range report.Workload.Operations[len(report.Workload.Operations)-5:] { + foreign := i + if foreign >= 2 { + foreign++ + } + target := op.Arguments["target"].(map[string]any)["symbol"] + if op.name() != "change.impact" || target != corpora[foreign].Label+"/"+corpora[foreign].Symbol { + t.Fatal("foreign target route lost or replaced with B") + } + } + before := report.RequestPlan + for i := range corpora { + corpora[i].Source = filepath.Join("/other-owned-copy", corpora[i].Label) + } + moved, err := newForeignImpactRunReport(o, corpora, 2, corpora[0].Commit, Host{}, "other-report") + if err != nil || moved.RequestPlan != before { + t.Fatal("private clone paths changed the actual foreign workload hash") + } + differentB, err := newForeignImpactRunReport(o, corpora, 1, corpora[0].Commit, Host{}, "other-report") + if err != nil || differentB.RequestPlan == before { + t.Fatal("foreign workload hash omitted edited-repository routing") + } +} From 0f982eded3bbc28913482d2d60f8d090ff159291 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:30:18 +0100 Subject: [PATCH 069/138] docs: describe shared freshness decoration for every mode --- internal/mcp/freshness_rider.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index c0e35387a..95ff7a3c3 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -489,10 +489,9 @@ func collectGraphFilePaths(v any) []string { return out } -// decorateResultWithFreshness attaches the freshness rider to a JSON-object -// tool response under the "freshness" key. Non-JSON-object payloads -// (GCX / TOON / arrays) are left untouched — a best-effort hint must never -// reshape a compact wire format the caller opted into. +// decorateResultWithFreshness merges freshness into JSON-object responses +// and renders an in-band label for other output modes. Freshness-free +// responses pass through unchanged. func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) *mcp.CallToolResult { if len(rider) == 0 { return res From d2ad5c3a26f7a6c6b952cdc957383c69b9f9524c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:35:43 +0100 Subject: [PATCH 070/138] feat(bench): add explicit gate modes and truthful budget evaluator --- bench/check-budgets/edit_loop.go | 353 ++++++++++++++++++++++ bench/check-budgets/main.go | 2 + bench/check-budgets/reports.go | 7 +- bench/load/edit_gate_facade.go | 42 +++ bench/load/edit_output_decode.go | 10 + bench/load/edit_probe_plan.go | 17 +- bench/load/edit_probe_run.go | 3 +- bench/load/run.go | 5 + bench/load/testdata/paired-v1.schema.json | 15 + bench/load/testdata/report-v1.schema.json | 15 + 10 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 bench/check-budgets/edit_loop.go create mode 100644 bench/load/edit_gate_facade.go diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go new file mode 100644 index 000000000..6098788ca --- /dev/null +++ b/bench/check-budgets/edit_loop.go @@ -0,0 +1,353 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "slices" + "strings" + "text/tabwriter" +) + +type editLoopRow struct { + Metric string `json:"metric"` + Scenario string `json:"scenario"` + State string `json:"state"` + Value *float64 `json:"value"` + Budget *float64 `json:"budget"` + Unit string `json:"unit"` + Baseline *float64 `json:"baseline"` + Delta *float64 `json:"delta"` + Reason string `json:"reason"` +} + +type editLoopWire struct { + Plan struct { + GateModes bool `json:"gate_modes"` + Count int `json:"count"` + Editors int `json:"editors"` + Mode string `json:"mode"` + Session string `json:"session_model"` + Steps []struct { + Ordinal int `json:"ordinal"` + Mode string `json:"probe_mode"` + } `json:"steps"` + } `json:"plan"` + Edits []struct { + Ordinal int `json:"ordinal"` + Mode string `json:"probe_mode"` + Outcome string `json:"outcome"` + ACK metric `json:"t_ack_ms"` + OneRead metric `json:"t_gate_readback_ms"` + ACKExact *bool `json:"ack_readback_exact"` + Readbacks []struct { + Kind string `json:"kind"` + Correct *bool `json:"correct"` + Error string `json:"error"` + ObservationOnly bool `json:"observation_only"` + } `json:"readbacks"` + Errors []string `json:"errors"` + } `json:"edits"` + Modes map[string]struct { + Answers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + Observable bool `json:"claims_fresh_observable"` + Stale metric `json:"stale_claimed_fresh"` + } `json:"output_modes"` +} + +func editLoopCommand(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("check-budgets edit-loop", flag.ContinueOnError) + flags.SetOutput(stderr) + policyPath := flags.String("budgets", "bench/budgets.yaml", "budget policy") + inputsPath := flags.String("inputs", "", "pinned repeated edit-probe reports") + scenario := flags.String("scenario", "", "explicit edit-load cell") + output := flags.String("out", "", "new JSON evidence output") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 || *inputsPath == "" || *scenario == "" { + fmt.Fprintln(stderr, "--inputs and --scenario are required") + return 2 + } + data, err := os.ReadFile(*policyPath) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + policy, err := loadPolicy(data) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + var budgets []budget + for _, row := range policy.Budgets { + if row.HostClass == "reference_linux" && row.Scenario == *scenario && slices.Contains(row.Lane, "edit-smoke") { + budgets = append(budgets, row) + } + } + if len(budgets) == 0 { + fmt.Fprintln(stderr, "scenario has no declared edit-loop gate rows") + return 2 + } + dataset, loadErr := loadInputsChecked(*inputsPath, validateEditLoopReport) + rows := make([]editLoopRow, 0, len(budgets)) + pass := true + for _, b := range budgets { + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + if loadErr != nil { + row.Reason = loadErr.Error() + } else { + row = assessEditLoopRow(b, dataset) + } + if row.State != "PASS" { + pass = false + } + rows = append(rows, row) + } + writer := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(writer, "scenario\tmetric\tvalue\tbudget\tunit\tbaseline\tdelta\tstate\treason") + for _, row := range rows { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", row.Scenario, row.Metric, editLoopNumber(row.Value), editLoopNumber(row.Budget), row.Unit, editLoopNumber(row.Baseline), editLoopNumber(row.Delta), row.State, row.Reason) + } + if err := writer.Flush(); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if *output != "" { + if err := writeNewJSON(*output, struct { + Pass bool `json:"pass"` + Rows []editLoopRow `json:"rows"` + }{pass, rows}); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + if !pass { + return 1 + } + return 0 +} + +func editLoopNumber(value *float64) string { + if value == nil { + return "unavailable" + } + return fmt.Sprintf("%.6g", *value) +} + +func validateEditLoopReport(config inputs, input runInput, r report) error { + if r.Version != 1 || !r.Complete || r.RunID == "" || r.Revision != map[string]string{"candidate": config.CandidateRevision, "main": config.MainRevision, "tag": config.TagRevision}[input.Role] || r.Profile != "agent" || !planPattern.MatchString(r.Plan) || len(r.Errors) != 0 || len(r.Phases) != 1 { + return errors.New("incomplete or unpinned edit-loop report") + } + if err := validateHost(input.HostClass, config.Hosts[input.HostClass], r); err != nil { + return err + } + for _, name := range []string{"panic_lines", "sigabrt_lines", "oom_log_lines"} { + if r.Crashes[name] == nil || *r.Crashes[name] != 0 { + return errors.New("missing or nonzero safety census") + } + } + phase := r.Phases[0] + if len(phase.Errors) != 0 || phase.Duration <= 0 || phase.Duration > 900 { + return errors.New("edit-loop phase incomplete or exceeds the 15-minute gate") + } + if _, coupled := phase.Gate["paired_measurement_window"]; coupled { + return errors.New("paired child cannot supply standalone gate evidence") + } + if forced, err := number(phase.Metrics["force_killed"], "boolean"); err != nil || forced != 0 { + return errors.New("missing clean daemon-stop evidence") + } + var workload struct { + Scenario string `json:"scenario"` + Agents int `json:"agents"` + Editors int `json:"edit_agents"` + Session string `json:"session_model"` + } + if err := json.Unmarshal(r.Workload, &workload); err != nil { + return err + } + expected := scenarioSpecs[input.Scenario].editors + if input.Scenario == "loadedit-4-largefile" { + expected = 4 + } + if workload.Scenario != "edit-and-probe" || workload.Agents != 0 || workload.Editors != expected || workload.Session != "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" { + return errors.New("incorrect edit-loop workload population or session model") + } + wire, err := decodeEditLoopWire(phase) + if err != nil { + return err + } + if wire.Plan.Editors != expected { + return errors.New("plan editor population differs from workload") + } + return nil +} + +func decodeEditLoopWire(phase phase) (editLoopWire, error) { + var wire editLoopWire + if err := json.Unmarshal(phase.EditProbe, &wire); err != nil { + return wire, fmt.Errorf("missing edit-probe evidence: %w", err) + } + if !wire.Plan.GateModes || wire.Plan.Count < 100 || len(wire.Edits) != wire.Plan.Count || len(wire.Plan.Steps) != wire.Plan.Count || wire.Plan.Mode != "json" { + return wire, errors.New("gate needs the complete >=100-total JSON-ACK/five-mode denominator") + } + modes := []string{"json", "toon", "compact", "facade", "cli"} + for i, record := range wire.Edits { + if record.Ordinal != i || wire.Plan.Steps[i].Ordinal != i || record.Mode != modes[i%5] || wire.Plan.Steps[i].Mode != record.Mode || record.Outcome != "complete" || len(record.Errors) != 0 { + return wire, errors.New("missing, duplicate, failed or incorrectly routed gate edit") + } + } + return wire, nil +} + +func assessEditLoopRow(b budget, dataset dataset) editLoopRow { + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + values := make([]float64, 0, dataset.config.repeats(b.Scenario)) + verdict := -1 + for repeat := 1; repeat <= dataset.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range dataset.runs { + if observation.input.Role != "candidate" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + found = true + value, err := measureEditLoopRow(observation.data.Phases[0], b.Metric) + if err != nil { + row.Reason = err.Error() + return row + } + current := 0 + if editLoopCompare(value, *b.Target, b.Compare) { + current = 1 + } + if verdict != -1 && verdict != current { + row.State = "FAIL" + row.Reason = "same-build repeat verdicts disagree" + return row + } + verdict = current + values = append(values, value) + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + if len(values) == 0 { + row.Reason = "no actual observations" + return row + } + value := slices.Max(values) + if b.Compare == "ge" { + value = slices.Min(values) + } + row.Value = &value + row.State = "FAIL" + if verdict == 1 { + row.State = "PASS" + } + return row +} + +func editLoopCompare(value, target float64, compare string) bool { + switch compare { + case "le": + return value <= target + case "lt": + return value < target + case "ge": + return value >= target + case "eq": + return value == target + } + return false +} + +func measureEditLoopRow(phase phase, name string) (float64, error) { + wire, err := decodeEditLoopWire(phase) + if err != nil { + return 0, err + } + switch name { + case "wal_p95_mib", "wal_peak_mib": + key := "p95_wal_bytes" + if name == "wal_peak_mib" { + key = "peak_wal_bytes" + } + value, err := number(phase.Metrics[key], "bytes") + return value / (1024 * 1024), err + case "read_gate_closures": + return number(phase.Metrics["reclaim_gate_closures"], "closures") + case "reclaim_hold_p95": + value, err := number(phase.Metrics["reclaim_writer_hold_p95_ms"], "ms") + return value / 1000, err + } + if mode, ok := strings.CutPrefix(name, "silent_stale_"); ok { + coverage, exists := wire.Modes[mode] + if !exists || coverage.Answers < 20 || coverage.DecodeFailures != 0 || !coverage.Observable { + return 0, errors.New("missing or unobservable actual output-mode coverage; absent freshness is never zero-stale") + } + return number(coverage.Stale, "answers") + } + var values []float64 + correct, total := 0, 0 + for _, record := range wire.Edits { + if name == "edit_ack_p95" || name == "edit_ack_max" { + if record.ACKExact == nil || !*record.ACKExact { + return 0, errors.New("ACK lacks exact readback proof") + } + value, err := number(record.ACK, "ms") + if err != nil { + return 0, err + } + values = append(values, value/1000) + } else if name == "readback_p95" || name == "readback_max" { + value, err := number(record.OneRead, "ms") + if err != nil { + return 0, err + } + values = append(values, value/1000) + } else if name == "readback_correct" { + seen := map[string]bool{} + for _, read := range record.Readbacks { + if read.ObservationOnly { + continue + } + if seen[read.Kind] { + return 0, errors.New("duplicate immediate readback") + } + seen[read.Kind] = true + if read.Correct == nil { + return 0, errors.New("immediate readback correctness unavailable") + } + total++ + if *read.Correct && read.Error == "" { + correct++ + } + } + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + if !seen[kind] { + return 0, fmt.Errorf("missing immediate %s readback", kind) + } + } + } + } + if name == "readback_correct" { + if total == 0 { + return 0, errors.New("no readbacks") + } + return float64(correct) / float64(total), nil + } + if len(values) == 0 { + return 0, errors.New("required edit-loop measure unavailable") + } + slices.Sort(values) + if strings.HasSuffix(name, "_max") { + return values[len(values)-1], nil + } + return values[(95*len(values)+99)/100-1], nil +} diff --git a/bench/check-budgets/main.go b/bench/check-budgets/main.go index 035e329f8..20dd29f16 100644 --- a/bench/check-budgets/main.go +++ b/bench/check-budgets/main.go @@ -19,6 +19,8 @@ func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { if len(args) > 0 { switch args[0] { + case "edit-loop": + return editLoopCommand(args[1:], stdout, stderr) case "plan", "collect": return laneCommand(args[0], args[1:], stdout, stderr) case "history": diff --git a/bench/check-budgets/reports.go b/bench/check-budgets/reports.go index ea8b2833b..c3c8658c8 100644 --- a/bench/check-budgets/reports.go +++ b/bench/check-budgets/reports.go @@ -61,6 +61,7 @@ type attempt struct { } type phase struct { + EditProbe json.RawMessage `json:"edit_probe,omitempty"` SessionModel string `json:"session_model"` Name string `json:"name"` Duration float64 `json:"duration_seconds"` @@ -110,7 +111,9 @@ type dataset struct { runs []observation } -func loadInputs(path string) (dataset, error) { +func loadInputs(path string) (dataset, error) { return loadInputsChecked(path, validateReport) } + +func loadInputsChecked(path string, validate func(inputs, runInput, report) error) (dataset, error) { var out dataset data, err := os.ReadFile(path) if err != nil { @@ -166,7 +169,7 @@ func loadInputs(path string) (dataset, error) { if err != nil { return out, fmt.Errorf("%s: %w", cell, err) } - if err := validateReport(c, input, r); err != nil { + if err := validate(c, input, r); err != nil { return out, fmt.Errorf("%s: %w", cell, err) } if ids[r.RunID] { diff --git a/bench/load/edit_gate_facade.go b/bench/load/edit_gate_facade.go new file mode 100644 index 000000000..62d5143fe --- /dev/null +++ b/bench/load/edit_gate_facade.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" +) + +type editProbeFacadeWire struct{ operation string } + +const editProbeFacadeWireKey = "\x00load.facade.wire" + +// Surface identity is proof from the actual MCP envelope, independent from +// its JSON payload. A GCX/TOON decoder or structured sibling cannot supply it. +func verifyEditProbeFacade(raw json.RawMessage, operation string) error { + canonical := map[string]string{ + "read.file": "read_file", "read.source": "get_symbol_source", "read.summary": "get_file_summary", "read.editing_context": "get_editing_context", + "search.symbols": "search_symbols", "search.text": "search_text", "relations.callers": "get_callers", "trace.call_chain": "get_call_chain", + }[operation] + facade, op, ok := strings.Cut(operation, ".") + if !ok || canonical == "" { + return errors.New("facade dispatch proof unavailable for this operation") + } + var wire struct { + Meta struct { + Identity struct { + Surface string `json:"surface_version"` + Facade string `json:"facade"` + Operation string `json:"operation"` + Canonical string `json:"canonical_tool"` + } `json:"gortex_facade"` + } `json:"_meta"` + } + if err := json.Unmarshal(raw, &wire); err != nil { + return err + } + identity := wire.Meta.Identity + if identity.Surface != "facade-v1" || identity.Facade != facade || identity.Operation != op || identity.Canonical != canonical { + return errors.New("missing or mismatched facade-v1 dispatch identity") + } + return nil +} diff --git a/bench/load/edit_output_decode.go b/bench/load/edit_output_decode.go index ffc12113d..9b68abdce 100644 --- a/bench/load/edit_output_decode.go +++ b/bench/load/edit_output_decode.go @@ -31,6 +31,13 @@ const editProbeTOONWireKey = "\x00load.toon.wire" var errEditOutputGCXUnavailable = errors.New("GCX output unavailable: no GCX decoder, and read_file answers a GCX request with TOON") func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[string]any, error) { + facade := format == "facade" + if facade { + if err := verifyEditProbeFacade(raw, operation); err != nil { + return nil, err + } + format = "json" + } switch format { case "", "json": case "toon": @@ -57,6 +64,9 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err := decoder.Decode(&body); err != nil { return nil, err } + if facade { + body[editProbeFacadeWireKey] = editProbeFacadeWire{operation: operation} + } return body, nil } if json.Valid(text) { diff --git a/bench/load/edit_probe_plan.go b/bench/load/edit_probe_plan.go index ea9e4a8e4..8cd221093 100644 --- a/bench/load/edit_probe_plan.go +++ b/bench/load/edit_probe_plan.go @@ -24,6 +24,7 @@ const editProbeMaxEditors = 32 const editProbeFixedEditorModel = "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" type editProbeStep struct { + ProbeMode string `json:"probe_mode,omitempty"` Ordinal int `json:"ordinal"` ActivateNS int64 `json:"activate_ns"` DueNS int64 `json:"due_ns"` @@ -60,6 +61,7 @@ type editProbeRoute struct { } type editProbePlan struct { + GateModes bool `json:"gate_modes,omitempty"` Version int `json:"version"` Arrival string `json:"arrival"` MeanNS int64 `json:"mean_interarrival_ns"` @@ -134,9 +136,22 @@ func planEditProbe(c Corpus, o runOptions) (editProbePlan, error) { mean = editProbeMean } p, err := makeEditProbeTrafficPlanWithMean(c, o.Seed, o.EditCount, o.Editors, mean) - if err != nil || o.Agents == 0 { + if err != nil { return p, err } + if o.EditGateModes { + if p.Count != 100 || p.Mode != "json" || p.Kind != "add_intrafile_call" || p.Tool != "edit.file" { + return editProbePlan{}, errors.New("five-mode gate requires 100 call-addition edits with unchanged JSON edit.file ACKs") + } + p.GateModes = true + modes := []string{"json", "toon", "compact", "facade", "cli"} + for i := range p.Steps { + p.Steps[i].ProbeMode = modes[i%len(modes)] + } + } + if o.Agents == 0 { + return p, nil + } if c.EditSender == editProbeRestart { return editProbePlan{}, errors.New("the restart cell has no pure readers") } diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index eaa1b3232..7b5552078 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -29,6 +29,7 @@ type editProbeReadback struct { ObservationOnly bool `json:"observation_only,omitempty"` } type editProbeRecord struct { + ProbeMode string `json:"probe_mode,omitempty"` Ordinal int `json:"ordinal"` Editor int `json:"editor,omitempty"` Route string `json:"route,omitempty"` @@ -137,7 +138,7 @@ type editProbeRuntime struct { } func newEditProbeRecord(c Corpus, step editProbeStep) editProbeRecord { - record := editProbeRecord{Ordinal: step.Ordinal, Editor: step.Editor, Route: step.Route, Marker: step.Marker, MutationID: step.MutationID, PlannedDueNS: step.DueNS, Outcome: "phase_censored", Ack: unavailable("ms", "edit not sent"), PhasesMS: editProbePhases(nil, "no receipt status observed"), Readback: unavailable("ms", "edit not acknowledged"), Derived: unavailable("ms", "no receipt status observed"), FirstCorrect: make(map[string]Metric), Accounting: editProbeAccounting{State: "not_observed"}, Errors: []string{"scenario never reached edit dispatch"}} + record := editProbeRecord{ProbeMode: step.ProbeMode, Ordinal: step.Ordinal, Editor: step.Editor, Route: step.Route, Marker: step.Marker, MutationID: step.MutationID, PlannedDueNS: step.DueNS, Outcome: "phase_censored", Ack: unavailable("ms", "edit not sent"), PhasesMS: editProbePhases(nil, "no receipt status observed"), Readback: unavailable("ms", "edit not acknowledged"), Derived: unavailable("ms", "no receipt status observed"), FirstCorrect: make(map[string]Metric), Accounting: editProbeAccounting{State: "not_observed"}, Errors: []string{"scenario never reached edit dispatch"}} for _, oracle := range editProbeOracles(c, step) { record.FirstCorrect[oracle.name] = unavailable("ms", "not observed") } diff --git a/bench/load/run.go b/bench/load/run.go index c6b09e748..cce5dd870 100644 --- a/bench/load/run.go +++ b/bench/load/run.go @@ -40,6 +40,7 @@ type runOptions struct { SweepBudget time.Duration EditMean time.Duration EditCount int + EditGateModes bool EditRepos int EditProbeRole string editProbeBaseline bool @@ -93,10 +94,14 @@ func bindRunFlags(f *flag.FlagSet, o *runOptions) { f.DurationVar(&o.EditMean, "edit-mean", 0, "edit-and-probe per-editor Poisson mean; 0 preserves the 15s default") f.IntVar(&o.EditRepos, "edit-repos", 1, "edit-and-probe: one repository (default), or exactly six distinct pinned repositories") f.IntVar(&o.EditCount, "edit-count", 100, "edit-and-probe: 100 logical edits, or explicit 1-edit diagnostic") + f.BoolVar(&o.EditGateModes, "edit-gate-modes", false, "edit-and-probe gate: rotate five probe surfaces over 100 total edits; JSON ACKs remain unchanged") f.StringVar(&o.EditProbeRole, "edit-probe-role", "", "edit-and-probe standalone role: candidate (default; phase accounting required) or baseline (absent phase timings reported unavailable)") } func validateRunOptions(o runOptions) error { + if o.EditGateModes && (o.Scenario != editProbeScenario || o.EditCount != 100 || o.Agents != 0) { + return errors.New("--edit-gate-modes requires edit-and-probe, 100 total edits and no extra pure readers") + } if o.EditMean < 0 || o.EditMean > 0 && o.Scenario != editProbeScenario { return errors.New("--edit-mean requires edit-and-probe and a nonnegative duration; 0 uses the 15s default") } diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index 828857679..cac43846f 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -1239,6 +1239,9 @@ "probe_errors": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "raw_evidence": { "type": "string" }, @@ -1780,6 +1783,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -1931,6 +1937,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, @@ -3621,6 +3630,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -3772,6 +3784,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index 7a70edad2..ba5a68a89 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -1226,6 +1226,9 @@ "probe_errors": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "raw_evidence": { "type": "string" }, @@ -1767,6 +1770,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -1918,6 +1924,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, @@ -3608,6 +3617,9 @@ "file": { "type": "string" }, + "gate_modes": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -3759,6 +3771,9 @@ "ordinal": { "type": "integer" }, + "probe_mode": { + "type": "string" + }, "relocated": { "type": "boolean" }, From 417fe2a7a30fa0840a591a7a03cd9c1bec7e1bb0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:37:11 +0100 Subject: [PATCH 071/138] reach: retire notification channels with their actual waiters --- internal/reach/reach.go | 38 ++++++++---- internal/reach/topology_bubble_test.go | 83 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 internal/reach/topology_bubble_test.go diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 8d28000b1..9e6aa6aa5 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -165,17 +165,20 @@ type topologyGate struct { // A scope is nil for the whole graph, or a sorted list of repository // prefixes. One mutex guards every gate, so a mutation takes all of its gates // in one step and gates need no lock order among themselves. Waiters sleep on -// wake, which is closed and replaced whenever a holder leaves. +// wake, which is allocated only while callers are waiting and cleared on +// broadcast or when its last waiter leaves. In particular, an idle state must +// not retain a channel owned by a caller's synctest bubble. // // The state is process-global, like buildCounter. Repository gates are created // on first use and dropped once idle, so the map holds only work in flight. // Production multi-repo indexers share one Store; tests that reuse a prefix // across stores only over-serialise. type topologyState struct { - mu sync.Mutex - wake chan struct{} - all topologyGate - repos map[string]*topologyGate + mu sync.Mutex + wake chan struct{} + wakeWaitersCount int // Waiters of wake only; broadcast generations retire independently. + all topologyGate + repos map[string]*topologyGate // Readers, writers and waiting writers of repository scopes, so that // whole-graph admission need not scan repos. repoReaders, repoWriters, repoWritersWaiting int @@ -189,7 +192,6 @@ type topologyState struct { } var topology = &topologyState{ - wake: make(chan struct{}), repos: make(map[string]*topologyGate), repoBuild: make(map[string]uint64), } @@ -261,9 +263,21 @@ func (t *topologyState) add(scope []string, readers, writers, waiting int) { // wait sleeps until a holder leaves and reports false when ctx ends first. // t.mu is held on entry and on return. func (t *topologyState) wait(ctx context.Context) bool { + if t.wake == nil { + t.wake = make(chan struct{}) + } wake := t.wake + t.wakeWaitersCount++ t.mu.Unlock() - defer t.mu.Lock() + defer func() { + t.mu.Lock() + if t.wake == wake { + t.wakeWaitersCount-- + if t.wakeWaitersCount == 0 { + t.wake = nil + } + } + }() select { case <-wake: return true @@ -272,10 +286,14 @@ func (t *topologyState) wait(ctx context.Context) bool { } } -// wakeWaiters lets every waiter re-check its admission. t.mu is held. +// wakeWaiters lets every waiter re-check its admission. t.mu is held. A +// replacement is created by the next actual waiter, never by the releaser. func (t *topologyState) wakeWaiters() { - close(t.wake) - t.wake = make(chan struct{}) + if t.wake != nil { + close(t.wake) + t.wake = nil + t.wakeWaitersCount = 0 + } } // retire moves the reach generation of scope past every value issued so far. diff --git a/internal/reach/topology_bubble_test.go b/internal/reach/topology_bubble_test.go new file mode 100644 index 000000000..e8ec3ec8d --- /dev/null +++ b/internal/reach/topology_bubble_test.go @@ -0,0 +1,83 @@ +package reach + +import ( + "context" + "testing" + "testing/synctest" +) + +// A real admission/release inside a fake-clock bubble must not leave a +// bubble-owned notification channel for the next ordinary graph operation. +func TestTopologyWakeDoesNotEscapeSynctestBubble(t *testing.T) { + for range 2 { + synctest.Test(t, func(t *testing.T) { + finish := beginTopologyWrite(nil, "") + finish(false, nil) + }) + finish := beginTopologyWrite(nil, "") + finish(false, nil) + } +} + +// The holder can outlive a cancelled reader. Once that reader has returned, +// its bubble must not remain the owner of the global notification channel. +func TestTopologyCancelledWaitDoesNotEscapeSynctestBubble(t *testing.T) { + finish := beginTopologyWrite(nil, "") + defer finish(false, nil) + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + release, admitted := beginTopologyRead(ctx, nil) + if admitted { + release() + t.Error("reader entered while writer held the graph") + } + }() + synctest.Wait() + cancel() + <-done + }) + finish(false, nil) +} + +func TestTopologyWakeKeepsOtherWaitersWhenOneCancels(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + finish := beginTopologyWrite(nil, "") + defer finish(false, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancelled := make(chan struct{}) + entered := make(chan struct{}) + go func() { + defer close(cancelled) + release, admitted := beginTopologyRead(ctx, nil) + if admitted { + release() + t.Error("cancelled reader entered while writer held the graph") + } + }() + go func() { + release, admitted := beginTopologyRead(context.Background(), nil) + if !admitted { + t.Error("live waiter was cancelled by another reader") + return + } + release() + close(entered) + }() + synctest.Wait() + cancel() + <-cancelled + synctest.Wait() + select { + case <-entered: + t.Fatal("live reader entered before writer release") + default: + } + finish(false, nil) + <-entered + }) + finish := beginTopologyWrite(nil, "") + finish(false, nil) +} From 8578b408d2b33ec5c52d103a63fa0e2b665e9c4f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:41:20 +0100 Subject: [PATCH 072/138] test: align legacy decorator checks with all-mode freshness --- internal/mcp/freshness_rider_test.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/mcp/freshness_rider_test.go b/internal/mcp/freshness_rider_test.go index 213148f8c..36a2a9d99 100644 --- a/internal/mcp/freshness_rider_test.go +++ b/internal/mcp/freshness_rider_test.go @@ -54,10 +54,19 @@ func TestDecorateResultWithFreshness(t *testing.T) { require.Equal(t, float64(1), obj["x"]) require.NotNil(t, obj["freshness"]) - // Non-JSON-object payload (GCX/TOON) is left untouched. - got2 := decorateResultWithFreshness(mcp.NewToolResultText("GCX1 tool=foo\nrow1"), rider) - text2, _ := singleTextContent(got2) - require.Equal(t, "GCX1 tool=foo\nrow1", text2) + // The shared renderer labels compact formats too, retaining their rows. + for _, test := range []struct{ body, want string }{ + {"GCX1 tool=foo\nrow1", "GCX1 tool=foo file=a.go stale=true\n# file: pending (1 files)\nrow1"}, + {"items[1]{id}:\n one", "file: pending (1 files)\nitems[1]{id}:\n one"}, + {"row1", "file: pending (1 files)\nrow1"}, + } { + labelled := decorateResultWithFreshness(mcp.NewToolResultText(test.body), rider) + text, _ := singleTextContent(labelled) + require.Equal(t, test.want, text) + idle := decorateResultWithFreshness(mcp.NewToolResultText(test.body), nil) + idleText, _ := singleTextContent(idle) + require.Equal(t, test.body, idleText, "an idle response remains byte-identical") + } // Empty rider is a no-op. got3 := decorateResultWithFreshness(mcp.NewToolResultText(`{"x":1}`), nil) From d6012dae2d720d8aa901fcdfca4db387aefb486b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 19:47:12 +0100 Subject: [PATCH 073/138] test: preserve legacy single-editor encoding in foreign reports --- bench/load/foreign_impact_driver_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bench/load/foreign_impact_driver_test.go b/bench/load/foreign_impact_driver_test.go index 8bfcbb793..52e768946 100644 --- a/bench/load/foreign_impact_driver_test.go +++ b/bench/load/foreign_impact_driver_test.go @@ -102,7 +102,7 @@ func TestForeignImpactRunReportDescribesActualRolesAndPreservesEditPlan(t *testi if report.Workload.Scenario != "foreign-impact" || report.Workload.Agents != 5 || report.Workload.EditAgents != 1 || report.Workload.SessionModel != "one_B_editor_and_five_independent_rotating_foreign_queries" { t.Fatalf("foreign workload mislabeled as ordinary read-only many cell: %+v", report.Workload) } - if report.Workload.EditProbe == nil || report.Workload.EditProbe.Count != 100 || report.Workload.EditProbe.Editors != 1 || report.Workload.EditProbe.Repo != corpora[2].Label || report.Workload.DurationSec != 600 { + if report.Workload.EditProbe == nil || report.Workload.EditProbe.Count != 100 || max(1, report.Workload.EditProbe.Editors) != 1 || report.Workload.EditProbe.Repo != corpora[2].Label || report.Workload.DurationSec != 600 { t.Fatal("active foreign window or full B edit schedule lost") } plan, err := foreignImpactEditPlan(corpora[2], 42) From ad578c4383f4d451bb962e5515878bc51e40f8d2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:14:48 +0100 Subject: [PATCH 074/138] feat(bench): retain actual edit gate dispatch and freshness evidence --- bench/check-budgets/edit_loop.go | 37 ++- .../check-budgets/edit_loop_evidence_test.go | 129 ++++++++ bench/load/edit_gate_facade.go | 13 +- bench/load/edit_gate_freshness.go | 144 +++++++++ bench/load/edit_gate_runtime_test.go | 303 ++++++++++++++++++ bench/load/edit_output_decode.go | 23 +- bench/load/edit_probe_output.go | 118 ++++++- bench/load/edit_probe_plan.go | 2 +- bench/load/edit_probe_run.go | 50 ++- bench/load/scenario.go | 14 + bench/load/testdata/paired-v1.schema.json | 123 +++++++ bench/load/testdata/report-v1.schema.json | 123 +++++++ 12 files changed, 1048 insertions(+), 31 deletions(-) create mode 100644 bench/check-budgets/edit_loop_evidence_test.go create mode 100644 bench/load/edit_gate_freshness.go create mode 100644 bench/load/edit_gate_runtime_test.go diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index 6098788ca..4317eadb8 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -26,6 +26,8 @@ type editLoopRow struct { type editLoopWire struct { Plan struct { + MeanNS *int64 `json:"mean_interarrival_ns"` + Arrival string `json:"arrival"` GateModes bool `json:"gate_modes"` Count int `json:"count"` Editors int `json:"editors"` @@ -52,6 +54,8 @@ type editLoopWire struct { Errors []string `json:"errors"` } `json:"edits"` Modes map[string]struct { + ObservedEdits *int `json:"observed_edits"` + ClaimsFresh *int `json:"claims_fresh"` Answers int `json:"probe_answers"` DecodeFailures int `json:"decode_failures"` Observable bool `json:"claims_fresh_observable"` @@ -152,9 +156,17 @@ func validateEditLoopReport(config inputs, input runInput, r report) error { } } phase := r.Phases[0] - if len(phase.Errors) != 0 || phase.Duration <= 0 || phase.Duration > 900 { + if phase.Name != "edit-and-probe" || phase.SessionModel == "" || len(phase.Errors) != 0 || !finiteNonnegative(phase.Duration) || phase.Duration <= 0 || phase.Duration > 900 { return errors.New("edit-loop phase incomplete or exceeds the 15-minute gate") } + var wall struct { + Clock string `json:"clock"` + NS *int64 `json:"wall_ns"` + Complete bool `json:"complete"` + } + if json.Unmarshal(phase.Gate["edit_loop_wall"], &wall) != nil || wall.Clock != "monotonic" || wall.NS == nil || *wall.NS <= 0 || *wall.NS > 900000000000 || float64(*wall.NS) < phase.Duration*1e9 || !wall.Complete { + return errors.New("missing, incomplete or over-15-minute whole-run wall observation") + } if _, coupled := phase.Gate["paired_measurement_window"]; coupled { return errors.New("paired child cannot supply standalone gate evidence") } @@ -174,14 +186,22 @@ func validateEditLoopReport(config inputs, input runInput, r report) error { if input.Scenario == "loadedit-4-largefile" { expected = 4 } - if workload.Scenario != "edit-and-probe" || workload.Agents != 0 || workload.Editors != expected || workload.Session != "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" { + session := "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" + if expected == 1 { + session = "one_editor_plus_one_separate_prober_per_edit" + } + if workload.Scenario != "edit-and-probe" || workload.Agents != 0 || workload.Editors != expected || workload.Session != session || phase.SessionModel != session { return errors.New("incorrect edit-loop workload population or session model") } wire, err := decodeEditLoopWire(phase) if err != nil { return err } - if wire.Plan.Editors != expected { + editors := wire.Plan.Editors + if editors == 0 && wire.Plan.Session == "one_editor_plus_one_separate_prober_per_edit" { + editors = 1 + } + if editors != expected || wire.Plan.Session != session { return errors.New("plan editor population differs from workload") } return nil @@ -192,6 +212,9 @@ func decodeEditLoopWire(phase phase) (editLoopWire, error) { if err := json.Unmarshal(phase.EditProbe, &wire); err != nil { return wire, fmt.Errorf("missing edit-probe evidence: %w", err) } + if wire.Plan.MeanNS == nil || *wire.Plan.MeanNS != 15000000000 || wire.Plan.Arrival != "precomputed_open_loop_poisson" { + return wire, errors.New("gate changed the offered 15-second-per-editor open-loop schedule") + } if !wire.Plan.GateModes || wire.Plan.Count < 100 || len(wire.Edits) != wire.Plan.Count || len(wire.Plan.Steps) != wire.Plan.Count || wire.Plan.Mode != "json" { return wire, errors.New("gate needs the complete >=100-total JSON-ACK/five-mode denominator") } @@ -288,7 +311,13 @@ func measureEditLoopRow(phase phase, name string) (float64, error) { } if mode, ok := strings.CutPrefix(name, "silent_stale_"); ok { coverage, exists := wire.Modes[mode] - if !exists || coverage.Answers < 20 || coverage.DecodeFailures != 0 || !coverage.Observable { + expected := 0 + for _, record := range wire.Edits { + if record.Mode == mode { + expected++ + } + } + if !exists || expected == 0 || coverage.ObservedEdits == nil || *coverage.ObservedEdits != expected || coverage.ClaimsFresh == nil || *coverage.ClaimsFresh <= 0 || coverage.Answers < expected || coverage.DecodeFailures != 0 || !coverage.Observable { return 0, errors.New("missing or unobservable actual output-mode coverage; absent freshness is never zero-stale") } return number(coverage.Stale, "answers") diff --git a/bench/check-budgets/edit_loop_evidence_test.go b/bench/check-budgets/edit_loop_evidence_test.go new file mode 100644 index 000000000..4ed156a31 --- /dev/null +++ b/bench/check-budgets/edit_loop_evidence_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func editLoopEvidenceFixture(t *testing.T) report { + t.Helper() + raw := syntheticReport() + workload := raw["workload"].(map[string]any) + workload["scenario"], workload["agents"], workload["edit_agents"], workload["session_model"] = "edit-and-probe", 0, 4, "fixed_editor_per_ordinal_plus_one_separate_prober_per_edit" + phase := raw["phases"].([]any)[0].(map[string]any) + phase["name"], phase["session_model"], phase["duration_seconds"] = "edit-and-probe", workload["session_model"], 600 + phase["gate"].(map[string]any)["edit_loop_wall"] = map[string]any{"clock": "monotonic", "wall_ns": int64(600000000000), "complete": true} + value := func(n float64, unit string) map[string]any { + return map[string]any{"value": n, "unit": unit, "reason": ""} + } + metrics := phase["metrics"].(map[string]any) + metrics["p95_wal_bytes"], metrics["peak_wal_bytes"] = value(32*1024*1024, "bytes"), value(64*1024*1024, "bytes") + metrics["reclaim_gate_closures"], metrics["reclaim_writer_holds"], metrics["reclaim_writer_hold_p95_ms"] = value(0, "closures"), value(4, "holds"), value(10, "ms") + modes := []string{"json", "toon", "compact", "facade", "cli"} + steps, records := make([]any, 100), make([]any, 100) + for i := range 100 { + mode := modes[i%5] + steps[i] = map[string]any{"ordinal": i, "probe_mode": mode, "editor": i % 4} + reads := make([]any, 0, 5) + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + reads = append(reads, map[string]any{"kind": kind, "correct": true, "error": "", "observation_only": false}) + } + records[i] = map[string]any{"ordinal": i, "probe_mode": mode, "outcome": "complete", "t_ack_ms": value(50, "ms"), "ack_readback_exact": true, "t_gate_readback_ms": value(100, "ms"), "readbacks": reads, "errors": []string{}} + } + coverage := map[string]any{} + for _, mode := range modes { + coverage[mode] = map[string]any{"probe_answers": 20, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 20, "claims_fresh_observable": true, "stale_claimed_fresh": value(0, "answers")} + } + phase["edit_probe"] = map[string]any{"plan": map[string]any{"gate_modes": true, "count": 100, "editors": 4, "mode": "json", "arrival": "precomputed_open_loop_poisson", "mean_interarrival_ns": int64(15000000000), "session_model": workload["session_model"], "steps": steps}, "edits": records, "output_modes": coverage} + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + result, err := decodeReport(data) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestEditLoopMeasurementsUseActualRecordsAndCounters(t *testing.T) { + r := editLoopEvidenceFixture(t) + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4"}, r); err != nil { + t.Fatal(err) + } + for name, want := range map[string]float64{"edit_ack_p95": .05, "edit_ack_max": .05, "readback_p95": .1, "readback_max": .1, "readback_correct": 1, "wal_p95_mib": 32, "wal_peak_mib": 64, "read_gate_closures": 0, "reclaim_hold_p95": .01, "silent_stale_json": 0} { + got, err := measureEditLoopRow(r.Phases[0], name) + if err != nil || got != want { + t.Errorf("%s=%v error%v, want%v", name, got, err, want) + } + } +} + +func TestEditLoopEvidenceRejectsWrongPhaseCadenceAndWall(t *testing.T) { + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + for _, variant := range []string{"phase_name", "coupled_phase", "cadence", "wall_missing", "wall_over15min"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + switch variant { + case "phase_name": + r.Phases[0].Name = "read" + case "coupled_phase": + r.Phases[0].SessionModel = "coupled_native" + case "wall_missing": + delete(r.Phases[0].Gate, "edit_loop_wall") + case "wall_over15min": + r.Phases[0].Gate["edit_loop_wall"] = json.RawMessage(`{"clock":"monotonic","wall_ns":901000000000,"complete":true}`) + case "cadence": + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + raw["plan"].(map[string]any)["mean_interarrival_ns"] = 1000000 + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + } + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4"}, r); err == nil { + t.Fatal("invalid timing/workload evidence accepted") + } + }) + } +} + +func TestEditLoopModeCoverageCannotBorrowAnotherEditOrMissingFreshClaims(t *testing.T) { + for _, variant := range []string{"one_edit", "no_claims", "missing_mode", "decode_failed", "unobservable"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + modes := raw["output_modes"].(map[string]any) + coverage := modes["json"].(map[string]any) + switch variant { + case "one_edit": + coverage["observed_edits"] = 1 + case "no_claims": + coverage["claims_fresh"] = 0 + case "missing_mode": + delete(modes, "json") + case "decode_failed": + coverage["decode_failures"] = 1 + case "unobservable": + coverage["claims_fresh_observable"] = false + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + if _, err := measureEditLoopRow(r.Phases[0], "silent_stale_json"); err == nil { + t.Fatal("missing/refused per-mode evidence became zero-stale") + } + }) + } +} diff --git a/bench/load/edit_gate_facade.go b/bench/load/edit_gate_facade.go index 62d5143fe..595f76a61 100644 --- a/bench/load/edit_gate_facade.go +++ b/bench/load/edit_gate_facade.go @@ -13,10 +13,8 @@ const editProbeFacadeWireKey = "\x00load.facade.wire" // Surface identity is proof from the actual MCP envelope, independent from // its JSON payload. A GCX/TOON decoder or structured sibling cannot supply it. func verifyEditProbeFacade(raw json.RawMessage, operation string) error { - canonical := map[string]string{ - "read.file": "read_file", "read.source": "get_symbol_source", "read.summary": "get_file_summary", "read.editing_context": "get_editing_context", - "search.symbols": "search_symbols", "search.text": "search_text", "relations.callers": "get_callers", "trace.call_chain": "get_call_chain", - }[operation] + canonical := editProbeCanonicalTool(operation) + facade, op, ok := strings.Cut(operation, ".") if !ok || canonical == "" { return errors.New("facade dispatch proof unavailable for this operation") @@ -40,3 +38,10 @@ func verifyEditProbeFacade(raw json.RawMessage, operation string) error { } return nil } + +func editProbeCanonicalTool(operation string) string { + return map[string]string{ + "read.file": "read_file", "read.source": "get_symbol_source", "read.summary": "get_file_summary", "read.editing_context": "get_editing_context", + "search.symbols": "search_symbols", "search.text": "search_text", "relations.callers": "get_callers", "trace.call_chain": "get_call_chain", + }[operation] +} diff --git a/bench/load/edit_gate_freshness.go b/bench/load/edit_gate_freshness.go new file mode 100644 index 000000000..3be07407b --- /dev/null +++ b/bench/load/edit_gate_freshness.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + wire "github.com/gortexhq/gcx-go" +) + +var editProbeTierLine = regexp.MustCompile(`^(file|relations|derived): (pending|pending_unknown|unknown|failed) \(([0-9]+) files\)$`) + +// The label is producer evidence, not disposable prose. Keep its exact scope, +// state and count while decoding the underlying compact/TOON payload normally. +func editProbeTierPrefix(text []byte) ([]byte, map[string]any, error) { + first, rest, found := bytes.Cut(text, []byte("\n")) + match := editProbeTierLine.FindSubmatch(first) + if match == nil { + return text, nil, nil + } + if !found { + return nil, nil, errors.New("freshness label has no payload boundary") + } + count, err := strconv.ParseUint(string(match[3]), 10, 63) + if err != nil { + return nil, nil, err + } + next, _, _ := bytes.Cut(rest, []byte("\n")) + if editProbeTierLine.Match(next) { + return nil, nil, errors.New("repeated freshness label") + } + return rest, map[string]any{"graph_freshness": string(match[2]), "freshness_scope": string(match[1]), "pending_file_count": json.Number(strconv.FormatUint(count, 10))}, nil +} + +func editProbeKeepTierFields(body, fields map[string]any) error { + for key, value := range fields { + if old, exists := body[key]; exists && fmt.Sprint(old) != fmt.Sprint(value) { + return errors.New("payload contradicts its freshness label") + } + body[key] = value + } + return nil +} + +// Only the existing GCX header decoder is used. Rows stay unsupported and can +// never supply payload/format credit from a TOON fallback or metadata alone. +func editProbeGCXTierFields(raw json.RawMessage, operation string) (map[string]any, error) { + text, err := editOutputPayload(raw, false) + if err != nil { + return nil, err + } + header, err := wire.NewDecoder(bytes.NewReader(text)).Header() + if err != nil { + return nil, err + } + if canonical := editProbeCanonicalTool(operation); canonical == "" || header.Tool != canonical { + return nil, errors.New("GCX header belongs to another operation") + } + state, scope, count := header.Meta["graph_freshness"], header.Meta["freshness_scope"], header.Meta["pending_file_count"] + if state == "" && scope == "" && count == "" { + return nil, nil + } + label := scope + ": " + state + " (" + count + " files)" + _, fields, err := editProbeTierPrefix([]byte(label + "\n")) + if err != nil || fields == nil { + return nil, errors.New("incomplete or invalid GCX freshness metadata") + } + _, rows, _ := bytes.Cut(text, []byte("\n")) + comment, _, _ := bytes.Cut(rows, []byte("\n")) + if bytes.HasPrefix(comment, []byte("# ")) { + observed := strings.TrimPrefix(string(comment), "# ") + if editProbeTierLine.MatchString(observed) && observed != label { + return nil, errors.New("GCX header and comment freshness disagree") + } + } + return fields, nil +} + +// Live daemon-clock observations remain separate from client and monotonic +// receipt windows. Missing stamps stay missing; equal clock cells are valid. +type editProbeLiveTiers struct { + State string `json:"observation_state"` + Reason string `json:"reason,omitempty"` + ACK string `json:"ack_at,omitempty"` + FileGraph string `json:"file_graph_at,omitempty"` + Relations string `json:"relations_at,omitempty"` + Derived string `json:"derived_at,omitempty"` + Tiers map[string]string `json:"tier_state,omitempty"` +} + +func editProbeStatusTiers(body map[string]any) *editProbeLiveTiers { + out := &editProbeLiveTiers{State: "observed"} + previous := time.Time{} + seen := false + for _, field := range []struct { + key string + target *string + }{{"ack_at", &out.ACK}, {"file_graph_at", &out.FileGraph}, {"relations_at", &out.Relations}, {"derived_at", &out.Derived}} { + raw, exists := body[field.key] + if !exists { + continue + } + seen = true + value, ok := raw.(string) + at, err := time.Parse(time.RFC3339Nano, value) + if !ok || err != nil || at.IsZero() || !previous.IsZero() && at.Before(previous) { + out.State, out.Reason = "invalid", "missing, malformed or reversed live tier stamp" + return out + } + *field.target = value + previous = at + } + if raw, exists := body["tier_state"]; exists { + seen = true + states, ok := raw.(map[string]any) + if !ok { + out.State, out.Reason = "invalid", "tier_state is not an object" + return out + } + out.Tiers = map[string]string{} + for _, tier := range []string{"file_graph", "relations", "derived"} { + value, ok := states[tier].(string) + if !ok || value != "fresh" && value != "pending" && value != "failed" && value != "partial" { + out.State, out.Reason = "invalid", "unknown live tier state" + return out + } + stamp := map[string]string{"file_graph": out.FileGraph, "relations": out.Relations, "derived": out.Derived}[tier] + if value == "fresh" && stamp == "" { + out.State, out.Reason = "invalid", "fresh live tier has no timestamp" + return out + } + out.Tiers[tier] = value + } + } + if !seen { + return nil + } + return out +} diff --git a/bench/load/edit_gate_runtime_test.go b/bench/load/edit_gate_runtime_test.go new file mode 100644 index 000000000..804fbd208 --- /dev/null +++ b/bench/load/edit_gate_runtime_test.go @@ -0,0 +1,303 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + toon "github.com/toon-format/toon-go" +) + +func TestEditGateRuntimeReadsEveryRequiredKindAndRetainsLiveTiers(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + // A one-record dispatch control, never accepted as the100-edit release gate. + plan.GateModes = true + plan.Steps[0].ProbeMode = "json" + step := plan.Steps[0] + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\nfunc Example() int { return 1 }\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + if frame.Method != "tools/call" { + return stubContent(map[string]any{}), nil + } + args, _ := frame.Params["arguments"].(map[string]any) + if frame.Params["name"] == "edit" && args["operation"] == "file" { + return stubContent(map[string]any{"mutation_receipt": "receipt-a", "readback_exact": true}), nil + } + if frame.Params["name"] == "change" && args["operation"] == "receipt" { + return stubContent(map[string]any{"receipt": "receipt-a", "mutation_id": step.MutationID, "graph_status_terminal": true, "graph_status": "fresh", "ack_at": "2026-01-01T00:00:00Z", "file_graph_at": "2026-01-01T00:00:00Z", "relations_at": "2026-01-01T00:00:00.001Z", "derived_at": "2026-01-01T00:00:00.002Z", "tier_state": map[string]any{"file_graph": "fresh", "relations": "fresh", "derived": "fresh"}}), nil + } + // Incorrect empty reads must stay errors, even when coverage is complete. + return stubContent(map[string]any{}), nil + }) + run.requireAccounting = false + run.doEdit(ctx, step) + state := run.states[0] + state.finishRaw() + seen := map[string]bool{} + for _, back := range state.record.Readbacks { + seen[back.Kind] = true + } + for _, kind := range []string{"file", "source", "summary", "search_visible", "text"} { + if !seen[kind] { + t.Errorf("gate never dispatched required same-editor immediate %s readback", kind) + } + } + if state.record.Readback.Value != nil || state.record.Outcome == "complete" { + t.Error("incorrect readbacks became a successful gate observation") + } + data, err := json.Marshal(state.record) + if err != nil { + t.Fatal(err) + } + var wire struct { + Tiers *struct { + ACK string `json:"ack_at"` + File string `json:"file_graph_at"` + Relations string `json:"relations_at"` + Derived string `json:"derived_at"` + States map[string]string `json:"tier_state"` + } `json:"mutation_tiers"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Tiers == nil { + t.Error("actual mutation_status tier stamps were discarded") + } else if wire.Tiers.ACK == "" || wire.Tiers.File != wire.Tiers.ACK || wire.Tiers.Relations == "" || wire.Tiers.Derived == "" { + t.Errorf("equal-clock causal stamps were lost or invented: %+v", wire.Tiers) + } + if wire.Tiers != nil { + for _, tier := range []string{"file_graph", "relations", "derived"} { + if wire.Tiers.States[tier] != "fresh" { + t.Errorf("live tier state %s was discarded: %+v", tier, wire.Tiers.States) + } + } + } +} + +func TestEditGateFreshnessPrefixSurvivesActualWireDecoding(t *testing.T) { + for _, mode := range []string{"compact", "toon", "gcx"} { + t.Run(mode, func(t *testing.T) { + for _, state := range []string{"pending", "failed"} { + var text string + switch mode { + case "compact": + text = "relations: " + state + " (2 files)\nfunction Added fixture/main.go:5\n" + case "toon": + encoded, err := toon.Marshal(map[string]any{"nodes": []any{map[string]any{"id": "fixture/main.go::Added"}}, "total": 1, "truncated": false}) + if err != nil { + t.Fatal(err) + } + text = "relations: " + state + " (2 files)\n" + string(encoded) + case "gcx": + text = "GCX1 tool=get_file_summary fields=id graph_freshness=" + state + " freshness_scope=relations pending_file_count=2\n# relations: " + state + " (2 files)\nfixture/main.go::Added\n" + } + body, err := decodeEditProbeOutput(outputWire(text, map[string]any{"graph_freshness": "fresh"}), mode, "read.summary") + if mode == "gcx" { + if !errors.Is(err, errEditOutputGCXUnavailable) { + t.Fatalf("GCX payload got unrequested decoder credit: %v", err) + } + } else if err != nil { + t.Errorf("actual %s freshness rider rejected: %v", mode, err) + continue + } + if body["graph_freshness"] != state || body["freshness_scope"] != "relations" || body["pending_file_count"] != json.Number("2") { + t.Errorf("discarded actual producer metadata (%s/%s): %+v", mode, state, body) + } + if editProbeClaimsFresh(body) { + t.Error("pending/failed text rider became a fresh claim") + } + } + }) + } +} + +func TestEditGateRuntimeInterpretsEachRecordsActualProbeSurface(t *testing.T) { + for _, mode := range []string{"toon", "compact", "facade", "cli"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + state := run.states[0] + defer state.finishRaw() + now := time.Now() + state.started, state.acknowledged = now.Add(-time.Millisecond), now.Add(-time.Microsecond) + oracle := editProbeOracle{name: "summary", call: request{Tool: "read", Operation: "summary", Arguments: map[string]any{"operation": "summary"}}} + oracle.call = editProbeWithOutput(oracle.call, mode) + var raw json.RawMessage + switch mode { + case "toon": + text, err := toon.Marshal(map[string]any{"fresh": true, "nodes": []any{}}) + if err != nil { + t.Fatal(err) + } + raw = outputWire(string(text), nil) + case "compact": + raw = outputWire("relations: failed (1 files)\nfunction "+plan.Steps[0].Marker+" fixture/"+c.File+":5\n", nil) + case "facade": + raw = outputWire(`{"fresh":true,"nodes":[]}`, nil) // Missing surface identity must not borrow JSON credit. + case "cli": + raw = json.RawMessage(`{"fresh":true,"nodes":[]}`) + } + if err := run.observeProbe(state, oracle, now, raw, wireCallIdentity{WriteStarted: now, Finished: now.Add(time.Microsecond)}, nil, false); err != nil { + t.Fatal(err) + } + got := run.modeCounts[mode] + if got.answers != 1 { + t.Errorf("actual %s answer was not counted in its record's mode: %+v", mode, run.modeCounts) + } + if run.modeCounts["json"].answers != 0 { + t.Error("non-JSON record borrowed JSON observations") + } + switch mode { + case "facade": + if got.decodeFailures != 1 || got.claimsFresh != 0 { + t.Error("missing facade identity was interpreted as a fresh JSON answer") + } + case "toon", "cli": + if got.decodeFailures != 0 || got.stale != 1 { + t.Error("actual decoded stale/fresh claim was discarded or attributed to another format") + } + case "compact": + if got.decodeFailures != 0 || got.claimsFresh != 0 { + t.Error("explicit failed metadata became a decode error or fresh claim") + } + } + }) + } +} + +func TestEditGateRuntimeRequestsTheAssignedWireForEachRecord(t *testing.T) { + for _, mode := range []string{"toon", "compact"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + seen := make(chan map[string]any, 1) + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + args, _ := frame.Params["arguments"].(map[string]any) + if frame.Method == "tools/call" && frame.Params["name"] == "read" && args["operation"] == "summary" { + output, _ := args["output"].(map[string]any) + select { + case seen <- output: + default: + } + } + return stubContent(map[string]any{}), nil // JSON cannot stand in for either requested wire. + }) + state := run.states[0] + defer state.finishRaw() + run.probe(ctx, cancel, state) + var output map[string]any + select { + case output = <-seen: + case <-ctx.Done(): + t.Error("assigned-mode request never reached actual socket dispatch") + } + cancel() + state.probes.Wait() + if mode == "toon" && output["format"] != "toon" { + t.Errorf("record sent%v instead of its TOON wire", output) + } + if mode == "compact" && output["compact"] != true { + t.Errorf("record sent%v instead of its compact producer request", output) + } + }) + } +} + +func TestEditGateModeCoverageCountsDistinctAssignedEditsAndActualTools(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 100) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode, plan.Steps[1].ProbeMode = "json", "compact" + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 2, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + for _, state := range run.states { + defer state.finishRaw() + } + now := time.Now() + identity := wireCallIdentity{WriteStarted: now, Finished: now.Add(time.Microsecond)} + summary := editProbeOracle{name: "summary", call: request{Tool: "read", Operation: "summary"}} + for range 2 { + if err := run.observeProbe(run.states[0], summary, now, outputWire(`{"fresh":true,"nodes":[]}`, nil), identity, nil, false); err != nil { + t.Fatal(err) + } + } + // Compact does not implement source. Its real JSON fallback must not + // lend another assigned edit or freshness claim to the JSON cell. + source := editProbeOracle{name: "source", call: request{Tool: "read", Operation: "source"}} + if err := run.observeProbe(run.states[1], source, now, outputWire(`{"fresh":true,"nodes":[]}`, nil), identity, nil, false); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(editProbeOutputCoverage("gate", run.modeCounts)) + if err != nil { + t.Fatal(err) + } + var coverage map[string]struct { + Observed *int `json:"observed_edits"` + Fresh int `json:"claims_fresh"` + Tools map[string]struct { + Answers int `json:"probe_answers"` + Edits int `json:"observed_edits"` + } `json:"tools"` + } + if err := json.Unmarshal(data, &coverage); err != nil { + t.Fatal(err) + } + jsonMode := coverage["json"] + if jsonMode.Observed == nil || *jsonMode.Observed != 1 || jsonMode.Fresh != 2 { + t.Errorf("repeated probes or another assigned mode borrowed edit/freshness credit: %s", data) + } + if tool := jsonMode.Tools["read.summary"]; tool.Answers != 2 || tool.Edits != 1 { + t.Errorf("actual dispatched tool census missing or inflated: %s", data) + } + if compact := coverage["compact"]; compact.Observed == nil || *compact.Observed != 0 { + t.Errorf("JSON fallback credited as compact: %s", data) + } + if _, exists := coverage["facade"]; !exists { + t.Errorf("facade mode omitted entirely: %s", data) + } +} diff --git a/bench/load/edit_output_decode.go b/bench/load/edit_output_decode.go index 9b68abdce..52c224072 100644 --- a/bench/load/edit_output_decode.go +++ b/bench/load/edit_output_decode.go @@ -46,7 +46,8 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s case "cli": return decodeEditProbeCLIOutput(raw) case "gcx": - return nil, errEditOutputGCXUnavailable + fields, err := editProbeGCXTierFields(raw, operation) + return fields, errors.Join(errEditOutputGCXUnavailable, err) default: return nil, errors.New("unsupported edit output mode") } @@ -54,6 +55,13 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err != nil { return nil, err } + var tierFields map[string]any + if format == "toon" { + text, tierFields, err = editProbeTierPrefix(text) + if err != nil { + return nil, err + } + } if format == "" || format == "json" { decoder := json.NewDecoder(bytes.NewReader(text)) decoder.UseNumber() @@ -95,6 +103,9 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err := decoder.Decode(&body); err != nil { return nil, err } + if err := editProbeKeepTierFields(body, tierFields); err != nil { + return nil, err + } body[editProbeTOONWireKey] = editProbeTOONWire{operation: operation} return body, nil } @@ -168,6 +179,10 @@ func decodeEditProbeCompactOutput(raw json.RawMessage, operation string) (map[st if err != nil { return nil, err } + text, tierFields, err := editProbeTierPrefix(text) + if err != nil { + return nil, err + } if json.Valid(text) { return nil, errors.New("requested compact text returned JSON; no format credit") } @@ -199,7 +214,11 @@ func decodeEditProbeCompactOutput(raw json.RawMessage, operation string) (map[st return nil, fmt.Errorf("compact line outside the %s grammar: %q", operation, line) } } - return map[string]any{editProbeCompactKey: answer}, nil + body := map[string]any{editProbeCompactKey: answer} + if err := editProbeKeepTierFields(body, tierFields); err != nil { + return nil, err + } + return body, nil } // `gortex call --format json` prints the tool's first text payload as JSON, diff --git a/bench/load/edit_probe_output.go b/bench/load/edit_probe_output.go index 4e392d548..c19c7f552 100644 --- a/bench/load/edit_probe_output.go +++ b/bench/load/edit_probe_output.go @@ -25,41 +25,85 @@ var editProbeOutputModes = []string{"json", "toon", "compact", "cli", "gcx"} type editProbeModes map[string]editProbeModeCoverage +type editProbeToolCoverage struct { + ProbeAnswers int `json:"probe_answers"` + ObservedEdits int `json:"observed_edits"` + DecodeFailures int `json:"decode_failures"` +} + type editProbeModeCoverage struct { - State string `json:"state"` - Reason string `json:"reason,omitempty"` - ProbeAnswers int `json:"probe_answers"` - DecodeFailures int `json:"decode_failures"` - ClaimsFreshObservable bool `json:"claims_fresh_observable"` - ClaimsFresh int `json:"claims_fresh"` - StaleClaimedFresh Metric `json:"stale_claimed_fresh"` - PrecommitMismatches int `json:"precommit_fresh_mismatches_unassessed"` + ObservedEdits *int `json:"observed_edits,omitempty"` + Tools map[string]editProbeToolCoverage `json:"tools,omitempty"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + ProbeAnswers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + ClaimsFreshObservable bool `json:"claims_fresh_observable"` + ClaimsFresh int `json:"claims_fresh"` + StaleClaimedFresh Metric `json:"stale_claimed_fresh"` + PrecommitMismatches int `json:"precommit_fresh_mismatches_unassessed"` } type editProbeModeCounts struct { answers, decodeFailures, claimsFresh, stale, precommit int + gate *editProbeGateCounts +} + +type editProbeGateCounts struct { + answers, decodeFailures, claimsFresh, stale, precommit int + edits map[int]bool + tools map[string]*editProbeGateToolCounts +} + +type editProbeGateToolCounts struct { + answers, decodeFailures int + edits map[int]bool } // Answers count by each oracle request's actual output mode. A mode without // answers, or whose payload cannot carry a freshness claim, reports its stale // claimed-fresh count unavailable rather than zero. func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCounts) editProbeModes { - out := make(editProbeModes, len(editProbeOutputModes)) - for _, mode := range editProbeOutputModes { + modes := editProbeOutputModes + if selected == "gate" { + modes = append(append([]string(nil), modes...), "facade") + } + out := make(editProbeModes, len(modes)) + for _, mode := range modes { count := counts[mode] + gate := count.gate + if selected == "gate" { + count = editProbeModeCounts{} + if gate != nil { + count.answers, count.decodeFailures, count.claimsFresh, count.stale, count.precommit = gate.answers, gate.decodeFailures, gate.claimsFresh, gate.stale, gate.precommit + } + } coverage := editProbeModeCoverage{State: "not_selected", ProbeAnswers: count.answers, DecodeFailures: count.decodeFailures, ClaimsFreshObservable: true, ClaimsFresh: count.claimsFresh, PrecommitMismatches: count.precommit} + if selected == "gate" { + observed := 0 + coverage.Tools = map[string]editProbeToolCoverage{} + if gate != nil { + observed = len(gate.edits) + for tool, count := range gate.tools { + coverage.Tools[tool] = editProbeToolCoverage{ProbeAnswers: count.answers, ObservedEdits: len(count.edits), DecodeFailures: count.decodeFailures} + } + } + coverage.ObservedEdits = &observed + } switch mode { case "gcx": coverage.State, coverage.Reason, coverage.ClaimsFreshObservable = "unavailable", errEditOutputGCXUnavailable.Error(), false case "compact": coverage.Reason, coverage.ClaimsFreshObservable = "compact text has no freshness field; it proves call-addition presence only", false } - if mode == selected { + if mode == selected || selected == "gate" && mode != "gcx" { coverage.State = "selected" } switch { case !coverage.ClaimsFreshObservable: coverage.StaleClaimedFresh = unavailable("answers", "mode cannot carry a freshness claim here") + case selected == "gate" && count.claimsFresh == 0: + coverage.StaleClaimedFresh = unavailable("answers", "no explicit freshness claims from assigned edits in this mode") case count.answers == 0: coverage.StaleClaimedFresh = unavailable("answers", "no probe answers in this mode") default: @@ -72,9 +116,17 @@ func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCou // A CLI probe sends the same JSON facade request through its own process, so // its answers are decoded and counted as CLI stdout, never as MCP JSON. -func (r *editProbeRuntime) probeOutput(oracle editProbeOracle) string { - if r.plan.Mode == "cli" { - return "cli" +func (r *editProbeRuntime) stepProbeMode(step editProbeStep) string { + if step.ProbeMode != "" { + return step.ProbeMode + } + return r.plan.Mode +} + +func (r *editProbeRuntime) probeOutput(oracle editProbeOracle, step editProbeStep) string { + mode := r.stepProbeMode(step) + if mode == "cli" || mode == "facade" { + return mode } return editProbeRequestOutput(oracle.call) } @@ -104,6 +156,44 @@ func (r *editProbeRuntime) countMode(mode string, answered, decoded, fresh, stal r.modeCounts[mode] = count } +// Gate witnesses belong to the record's assigned surface. A real JSON +// fallback remains counted as JSON traffic, but cannot lend edit coverage or +// freshness claims to either another record's JSON cell or its compact cell. +func (r *editProbeRuntime) countGateMode(step editProbeStep, mode, tool string, answered, decoded, fresh, stale, precommit bool) { + if !r.plan.GateModes || mode != step.ProbeMode || !answered { + return + } + r.mu.Lock() + defer r.mu.Unlock() + count := r.modeCounts[mode] + if count.gate == nil { + count.gate = &editProbeGateCounts{edits: map[int]bool{}, tools: map[string]*editProbeGateToolCounts{}} + } + gate := count.gate + gate.answers++ + if gate.tools[tool] == nil { + gate.tools[tool] = &editProbeGateToolCounts{edits: map[int]bool{}} + } + toolCount := gate.tools[tool] + toolCount.answers++ + if decoded { + gate.edits[step.Ordinal], toolCount.edits[step.Ordinal] = true, true + } else { + gate.decodeFailures++ + toolCount.decodeFailures++ + } + if fresh { + gate.claimsFresh++ + } + if stale { + gate.stale++ + } + if precommit { + gate.precommit++ + } + r.modeCounts[mode] = count +} + func editProbeRequestOutput(req request) string { output, exists := req.Arguments["output"] if !exists { diff --git a/bench/load/edit_probe_plan.go b/bench/load/edit_probe_plan.go index 8cd221093..4d1e5fa72 100644 --- a/bench/load/edit_probe_plan.go +++ b/bench/load/edit_probe_plan.go @@ -140,7 +140,7 @@ func planEditProbe(c Corpus, o runOptions) (editProbePlan, error) { return p, err } if o.EditGateModes { - if p.Count != 100 || p.Mode != "json" || p.Kind != "add_intrafile_call" || p.Tool != "edit.file" { + if p.Count != 100 || p.MeanNS != int64(editProbeMean) || p.Mode != "json" || p.Kind != "add_intrafile_call" || p.Tool != "edit.file" { return editProbePlan{}, errors.New("five-mode gate requires 100 call-addition edits with unchanged JSON edit.file ACKs") } p.GateModes = true diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index 7b5552078..b0e754778 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -29,6 +29,9 @@ type editProbeReadback struct { ObservationOnly bool `json:"observation_only,omitempty"` } type editProbeRecord struct { + MutationTiers *editProbeLiveTiers `json:"mutation_tiers,omitempty"` + GateReadback *Metric `json:"t_gate_readback_ms,omitempty"` + ACKReadbackExact *bool `json:"ack_readback_exact,omitempty"` ProbeMode string `json:"probe_mode,omitempty"` Ordinal int `json:"ordinal"` Editor int `json:"editor,omitempty"` @@ -629,7 +632,7 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl if !identity.WriteStarted.IsZero() { sent = identity.WriteStarted } - body, decodeErr := decodeEditProbeOutput(data, r.probeOutput(oracle), oracle.call.name()) + body, decodeErr := decodeEditProbeOutput(data, r.probeOutput(oracle, s.step), oracle.call.name()) absence, exactRefusal := err.(*editProbeExpectedAbsence) expectedAbsence := oracle.call.ExpectedAbsentSymbol != "" && exactRefusal && absence.symbol == repo.Prefix+"/"+oracle.call.ExpectedAbsentSymbol correct := expectedAbsence || err == nil && decodeErr == nil && editProbeCorrect(oracle.name, body, repo.Corpus, s.step, r.calleeFor(repo)) @@ -662,8 +665,9 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl } } s.mu.Unlock() - r.countMode(r.probeOutput(oracle), err == nil || expectedAbsence, err != nil || decodeErr == nil, fresh, stale, precommit) - return s.emit(map[string]any{"event": "probe", "oracle": oracle.name, "start_ns": editProbeOffset(sent, r.origin), "end_ns": editProbeOffset(ended, r.origin), "correct": correct, "claims_fresh": fresh, "receipt_observed_pending": pending}) + r.countMode(r.probeOutput(oracle, s.step), err == nil || expectedAbsence, err != nil || decodeErr == nil, fresh, stale, precommit) + r.countGateMode(s.step, r.probeOutput(oracle, s.step), oracle.call.name(), err == nil || expectedAbsence, decodeErr == nil, fresh, stale, precommit) + return s.emit(map[string]any{"event": "probe", "oracle": oracle.name, "requested_mode": r.stepProbeMode(s.step), "output_mode": r.probeOutput(oracle, s.step), "start_ns": editProbeOffset(sent, r.origin), "end_ns": editProbeOffset(ended, r.origin), "correct": correct, "claims_fresh": fresh, "receipt_observed_pending": pending}) } // Activation only starts owned loops and returns. It never waits for a probe @@ -682,7 +686,9 @@ func (r *editProbeRuntime) probe(probeCtx context.Context, cancel context.Cancel cancel() return } - for index, oracle := range editProbeOracles(repo.Corpus, s.step) { + corpus := repo.Corpus + corpus.EditOutput = r.stepProbeMode(s.step) + for index, oracle := range editProbeOracles(corpus, s.step) { s.probes.Go(func() { ticker := time.NewTicker(editProbePeriod) defer ticker.Stop() @@ -692,7 +698,7 @@ func (r *editProbeRuntime) probe(probeCtx context.Context, cancel context.Cancel var data json.RawMessage var identity wireCallIdentity var err error - if r.plan.Mode == "cli" { + if r.stepProbeMode(s.step) == "cli" { data, identity, err = r.performCLI(probeCtx, s, oracle.call, "oracle:"+oracle.name, 10+index) } else { data, identity, err = r.perform(probeCtx, s, s.client, oracle.call, "oracle:"+oracle.name, "probe", 10+index) @@ -824,6 +830,13 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.mu.Unlock() return } + if r.plan.GateModes { + s.mu.Lock() + if exact, ok := body["readback_exact"].(bool); ok { + s.record.ACKReadbackExact = &exact + } + s.mu.Unlock() + } receipt, _ := body["mutation_receipt"].(string) var move *editProbeMoveReferences var batch *editProbeBatchReferences @@ -883,6 +896,9 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.mu.Unlock() s.signalAck() readbackKinds := []string{"file", "source"} + if r.plan.GateModes { + readbackKinds = append(readbackKinds, "summary", "search_visible", "text") + } if step.BeforeSymbol != "" && !step.Deleted && step.TargetSymbol == "" { readbackKinds = append(readbackKinds, "old_source_absent") } @@ -925,6 +941,10 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.record.Accounting = accounting s.record.PhasesMS = editProbePhases(status, "status exposes no phases_ms") s.record.Derived = editProbeDerived(status) + s.record.MutationTiers = editProbeStatusTiers(status) + if s.record.MutationTiers != nil && s.record.MutationTiers.State == "invalid" { + s.record.Errors = append(s.record.Errors, "invalid live tier observation: "+s.record.MutationTiers.Reason) + } s.record.GraphTerminal = status["graph_status_terminal"] == true if s.record.GraphTerminal { r.settlePendingLocked(s) @@ -1020,6 +1040,13 @@ func (r *editProbeRuntime) immediateReadbacks(callCtx context.Context, s *editPr } } req := request{Tool: "read", Operation: kind, Arguments: map[string]any{"operation": kind, "target": target}} + if kind == "search_visible" || kind == "text" { + operation := "symbols" + if kind == "text" { + operation = "text" + } + req = request{Tool: "search", Operation: operation, Arguments: map[string]any{"operation": operation, "query": step.Marker}} + } if kind == "source" && step.TargetSymbol != "" { req.Arguments["options"] = map[string]any{"context_lines": 0} } @@ -1055,6 +1082,13 @@ func (r *editProbeRuntime) immediateReadbacks(callCtx context.Context, s *editPr } back.ObservationOnly = observeOnly[kind] s.mu.Lock() + if r.plan.GateModes && index == 0 { + metric := unavailable("ms", "first same-editor readback endpoint missing or incorrect") + if back.Correct && !identity.Finished.IsZero() && !s.started.IsZero() && !identity.Finished.Before(s.started) { + metric = measured(float64(identity.Finished.Sub(s.started))/float64(time.Millisecond), "ms") + } + s.record.GateReadback = &metric + } s.record.Readbacks = append(s.record.Readbacks, back) if !back.Correct && !back.ObservationOnly { s.record.Errors = append(s.record.Errors, "immediate "+kind+" readback incorrect") @@ -1148,7 +1182,11 @@ func (r *editProbeRuntime) finish() error { r.phase.EditProbe.Summary = summarizeEditProbe(r.phase.EditProbe.Records, r.plan.Seed) r.mu.Lock() r.phase.EditProbe.Reads = summarizeEditProbeReads(r.reads, r.plan.Seed) - r.phase.EditProbe.OutputModes = editProbeOutputCoverage(r.plan.Mode, r.modeCounts) + selected := r.plan.Mode + if r.plan.GateModes { + selected = "gate" + } + r.phase.EditProbe.OutputModes = editProbeOutputCoverage(selected, r.modeCounts) r.mu.Unlock() return errors.Join(failures...) } diff --git a/bench/load/scenario.go b/bench/load/scenario.go index 2bccac65e..54cd8b19e 100644 --- a/bench/load/scenario.go +++ b/bench/load/scenario.go @@ -14,6 +14,10 @@ import ( ) func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corpus, group, outputRoot string) (phase Phase, sweep []SweepEntry, artifacts map[string]string, crashes map[string]int, version string, resultErr error) { + var gateWallStart time.Time + if o.EditGateModes { + gateWallStart = time.Now() + } if scenario == startupScenario { return runStartupPhase(ctx, o, corpora, outputRoot) } @@ -63,6 +67,16 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp if resultErr == nil && len(phase.Errors) > 0 { resultErr = errors.New(strings.Join(phase.Errors, "; ")) } + if o.EditGateModes { + // This first-registered defer runs after owned cleanup, so readiness, + // measurement and normal stop all remain in the wall denominator. + wall := struct { + Clock string `json:"clock"` + NS int64 `json:"wall_ns"` + Complete bool `json:"complete"` + }{"monotonic", time.Since(gateWallStart).Nanoseconds(), resultErr == nil && len(phase.Errors) == 0} + phase.Gate["edit_loop_wall"], _ = json.Marshal(wall) + } }() worktreeViews := scenario == "worktrees" || scenario == editProbeScenario && o.Worktrees > 0 if scenario == editProbeScenario && (o.EditRepos > 1 || o.Worktrees > 0) { diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index cac43846f..61c9522d2 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -625,6 +625,16 @@ ], "type": "object" }, + "ack_readback_exact": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, "activated_ns": { "anyOf": [ { @@ -1028,6 +1038,49 @@ "mutation_receipt": { "type": "string" }, + "mutation_tiers": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ack_at": { + "type": "string" + }, + "derived_at": { + "type": "string" + }, + "file_graph_at": { + "type": "string" + }, + "observation_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "relations_at": { + "type": "string" + }, + "tier_state": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "observation_state" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "native_write": { "anyOf": [ { @@ -1569,6 +1622,40 @@ ], "type": "object" }, + "t_gate_readback_ms": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "value", + "unit", + "reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "t_readback_ms": { "additionalProperties": false, "properties": { @@ -1706,6 +1793,16 @@ "decode_failures": { "type": "integer" }, + "observed_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "precommit_fresh_mismatches_unassessed": { "type": "integer" }, @@ -1744,6 +1841,32 @@ }, "state": { "type": "string" + }, + "tools": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "decode_failures": { + "type": "integer" + }, + "observed_edits": { + "type": "integer" + }, + "probe_answers": { + "type": "integer" + } + }, + "required": [ + "probe_answers", + "observed_edits", + "decode_failures" + ], + "type": "object" + }, + "type": [ + "object", + "null" + ] } }, "required": [ diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index ba5a68a89..ced823872 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -612,6 +612,16 @@ ], "type": "object" }, + "ack_readback_exact": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, "activated_ns": { "anyOf": [ { @@ -1015,6 +1025,49 @@ "mutation_receipt": { "type": "string" }, + "mutation_tiers": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ack_at": { + "type": "string" + }, + "derived_at": { + "type": "string" + }, + "file_graph_at": { + "type": "string" + }, + "observation_state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "relations_at": { + "type": "string" + }, + "tier_state": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "observation_state" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "native_write": { "anyOf": [ { @@ -1556,6 +1609,40 @@ ], "type": "object" }, + "t_gate_readback_ms": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "value", + "unit", + "reason" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, "t_readback_ms": { "additionalProperties": false, "properties": { @@ -1693,6 +1780,16 @@ "decode_failures": { "type": "integer" }, + "observed_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "precommit_fresh_mismatches_unassessed": { "type": "integer" }, @@ -1731,6 +1828,32 @@ }, "state": { "type": "string" + }, + "tools": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "decode_failures": { + "type": "integer" + }, + "observed_edits": { + "type": "integer" + }, + "probe_answers": { + "type": "integer" + } + }, + "required": [ + "probe_answers", + "observed_edits", + "decode_failures" + ], + "type": "object" + }, + "type": [ + "object", + "null" + ] } }, "required": [ From db716eb77831e85837e74710e664eac69a3dd9f4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:17:35 +0100 Subject: [PATCH 075/138] fix(bench): enforce tool coverage and smoke-specific wall limits --- bench/check-budgets/edit_loop.go | 16 ++++- .../check-budgets/edit_loop_evidence_test.go | 68 ++++++++++++++++++- bench/load/edit_gate_runtime_test.go | 3 + bench/load/edit_probe_output.go | 8 ++- bench/load/edit_probe_run.go | 4 +- bench/load/testdata/paired-v1.schema.json | 6 +- bench/load/testdata/report-v1.schema.json | 6 +- 7 files changed, 101 insertions(+), 10 deletions(-) diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index 4317eadb8..578151c11 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -54,6 +54,12 @@ type editLoopWire struct { Errors []string `json:"errors"` } `json:"edits"` Modes map[string]struct { + Tools map[string]struct { + ObservedEdits *int `json:"observed_edits"` + Answers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + ClaimsFresh *int `json:"claims_fresh"` + } `json:"tools"` ObservedEdits *int `json:"observed_edits"` ClaimsFresh *int `json:"claims_fresh"` Answers int `json:"probe_answers"` @@ -156,7 +162,7 @@ func validateEditLoopReport(config inputs, input runInput, r report) error { } } phase := r.Phases[0] - if phase.Name != "edit-and-probe" || phase.SessionModel == "" || len(phase.Errors) != 0 || !finiteNonnegative(phase.Duration) || phase.Duration <= 0 || phase.Duration > 900 { + if phase.Name != "edit-and-probe" || phase.SessionModel == "" || len(phase.Errors) != 0 || !finiteNonnegative(phase.Duration) || phase.Duration <= 0 || input.Scenario == "loadedit-4" && phase.Duration > 900 { return errors.New("edit-loop phase incomplete or exceeds the 15-minute gate") } var wall struct { @@ -164,7 +170,7 @@ func validateEditLoopReport(config inputs, input runInput, r report) error { NS *int64 `json:"wall_ns"` Complete bool `json:"complete"` } - if json.Unmarshal(phase.Gate["edit_loop_wall"], &wall) != nil || wall.Clock != "monotonic" || wall.NS == nil || *wall.NS <= 0 || *wall.NS > 900000000000 || float64(*wall.NS) < phase.Duration*1e9 || !wall.Complete { + if json.Unmarshal(phase.Gate["edit_loop_wall"], &wall) != nil || wall.Clock != "monotonic" || wall.NS == nil || *wall.NS <= 0 || input.Scenario == "loadedit-4" && *wall.NS > 900000000000 || float64(*wall.NS) < phase.Duration*1e9 || !wall.Complete { return errors.New("missing, incomplete or over-15-minute whole-run wall observation") } if _, coupled := phase.Gate["paired_measurement_window"]; coupled { @@ -320,6 +326,12 @@ func measureEditLoopRow(phase phase, name string) (float64, error) { if !exists || expected == 0 || coverage.ObservedEdits == nil || *coverage.ObservedEdits != expected || coverage.ClaimsFresh == nil || *coverage.ClaimsFresh <= 0 || coverage.Answers < expected || coverage.DecodeFailures != 0 || !coverage.Observable { return 0, errors.New("missing or unobservable actual output-mode coverage; absent freshness is never zero-stale") } + for _, name := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + tool, exists := coverage.Tools[name] + if !exists || tool.ObservedEdits == nil || *tool.ObservedEdits != expected || tool.Answers < expected || tool.DecodeFailures != 0 || tool.ClaimsFresh == nil || *tool.ClaimsFresh <= 0 { + return 0, fmt.Errorf("missing actual %s/%s coverage or explicit freshness claims", mode, name) + } + } return number(coverage.Stale, "answers") } var values []float64 diff --git a/bench/check-budgets/edit_loop_evidence_test.go b/bench/check-budgets/edit_loop_evidence_test.go index 4ed156a31..28668aa87 100644 --- a/bench/check-budgets/edit_loop_evidence_test.go +++ b/bench/check-budgets/edit_loop_evidence_test.go @@ -33,7 +33,11 @@ func editLoopEvidenceFixture(t *testing.T) report { } coverage := map[string]any{} for _, mode := range modes { - coverage[mode] = map[string]any{"probe_answers": 20, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 20, "claims_fresh_observable": true, "stale_claimed_fresh": value(0, "answers")} + tools := map[string]any{} + for _, tool := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + tools[tool] = map[string]any{"probe_answers": 20, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 20} + } + coverage[mode] = map[string]any{"tools": tools, "probe_answers": 140, "observed_edits": 20, "decode_failures": 0, "claims_fresh": 140, "claims_fresh_observable": true, "stale_claimed_fresh": value(0, "answers")} } phase["edit_probe"] = map[string]any{"plan": map[string]any{"gate_modes": true, "count": 100, "editors": 4, "mode": "json", "arrival": "precomputed_open_loop_poisson", "mean_interarrival_ns": int64(15000000000), "session_model": workload["session_model"], "steps": steps}, "edits": records, "output_modes": coverage} data, err := json.Marshal(raw) @@ -127,3 +131,65 @@ func TestEditLoopModeCoverageCannotBorrowAnotherEditOrMissingFreshClaims(t *test }) } } + +func TestEditLoopModeCoverageRequiresEveryApplicableTool(t *testing.T) { + for _, variant := range []string{"missing_tool", "one_edit", "decode_failed", "missing_fresh_claim"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + tools := raw["output_modes"].(map[string]any)["json"].(map[string]any)["tools"].(map[string]any) + tool := tools["read.summary"].(map[string]any) + switch variant { + case "missing_tool": + delete(tools, "read.summary") + case "one_edit": + tool["observed_edits"] = 1 + case "decode_failed": + tool["decode_failures"] = 1 + case "missing_fresh_claim": + delete(tool, "claims_fresh") + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + if _, err := measureEditLoopRow(r.Phases[0], "silent_stale_json"); err == nil { + t.Fatal("another tool lent coverage or freshness credit to an unproved tool") + } + }) + } +} + +func TestEditLoopNightlySingleEditorRetainsItsOfferedSchedule(t *testing.T) { + r := editLoopEvidenceFixture(t) + var workload, raw map[string]any + if err := json.Unmarshal(r.Workload, &workload); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + session := "one_editor_plus_one_separate_prober_per_edit" + workload["edit_agents"], workload["session_model"] = 1, session + plan := raw["plan"].(map[string]any) + plan["editors"], plan["session_model"] = 1, session + var err error + r.Workload, err = json.Marshal(workload) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].SessionModel, r.Phases[0].Duration = session, 1500 + r.Phases[0].Gate["edit_loop_wall"] = json.RawMessage(`{"clock":"monotonic","wall_ns":1500000000000,"complete":true}`) + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + if err := validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-1"}, r); err != nil { + t.Fatalf("nightly's 100 edits at the original 15-second single-editor cadence got an invented smoke deadline: %v", err) + } +} diff --git a/bench/load/edit_gate_runtime_test.go b/bench/load/edit_gate_runtime_test.go index 804fbd208..c038a76ab 100644 --- a/bench/load/edit_gate_runtime_test.go +++ b/bench/load/edit_gate_runtime_test.go @@ -46,6 +46,9 @@ func TestEditGateRuntimeReadsEveryRequiredKindAndRetainsLiveTiers(t *testing.T) run.doEdit(ctx, step) state := run.states[0] state.finishRaw() + if len(state.record.Readbacks) == 0 || state.record.Readbacks[0].Kind != "source" { + t.Error("edit + one read must end at the first symbol-source/summary response, not read_file") + } seen := map[string]bool{} for _, back := range state.record.Readbacks { seen[back.Kind] = true diff --git a/bench/load/edit_probe_output.go b/bench/load/edit_probe_output.go index c19c7f552..57e61556a 100644 --- a/bench/load/edit_probe_output.go +++ b/bench/load/edit_probe_output.go @@ -29,6 +29,7 @@ type editProbeToolCoverage struct { ProbeAnswers int `json:"probe_answers"` ObservedEdits int `json:"observed_edits"` DecodeFailures int `json:"decode_failures"` + ClaimsFresh int `json:"claims_fresh"` } type editProbeModeCoverage struct { @@ -56,8 +57,8 @@ type editProbeGateCounts struct { } type editProbeGateToolCounts struct { - answers, decodeFailures int - edits map[int]bool + answers, decodeFailures, claimsFresh int + edits map[int]bool } // Answers count by each oracle request's actual output mode. A mode without @@ -85,7 +86,7 @@ func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCou if gate != nil { observed = len(gate.edits) for tool, count := range gate.tools { - coverage.Tools[tool] = editProbeToolCoverage{ProbeAnswers: count.answers, ObservedEdits: len(count.edits), DecodeFailures: count.decodeFailures} + coverage.Tools[tool] = editProbeToolCoverage{ProbeAnswers: count.answers, ObservedEdits: len(count.edits), DecodeFailures: count.decodeFailures, ClaimsFresh: count.claimsFresh} } } coverage.ObservedEdits = &observed @@ -183,6 +184,7 @@ func (r *editProbeRuntime) countGateMode(step editProbeStep, mode, tool string, toolCount.decodeFailures++ } if fresh { + toolCount.claimsFresh++ gate.claimsFresh++ } if stale { diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index b0e754778..c467e398f 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -897,7 +897,7 @@ func (r *editProbeRuntime) finishEditAfterACK(ctx, callCtx context.Context, s *e s.signalAck() readbackKinds := []string{"file", "source"} if r.plan.GateModes { - readbackKinds = append(readbackKinds, "summary", "search_visible", "text") + readbackKinds = []string{"source", "file", "summary", "search_visible", "text"} } if step.BeforeSymbol != "" && !step.Deleted && step.TargetSymbol == "" { readbackKinds = append(readbackKinds, "old_source_absent") @@ -1083,7 +1083,7 @@ func (r *editProbeRuntime) immediateReadbacks(callCtx context.Context, s *editPr back.ObservationOnly = observeOnly[kind] s.mu.Lock() if r.plan.GateModes && index == 0 { - metric := unavailable("ms", "first same-editor readback endpoint missing or incorrect") + metric := unavailable("ms", "first same-editor symbol-source readback endpoint missing or incorrect") if back.Correct && !identity.Finished.IsZero() && !s.started.IsZero() && !identity.Finished.Before(s.started) { metric = measured(float64(identity.Finished.Sub(s.started))/float64(time.Millisecond), "ms") } diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index 61c9522d2..a9f89b506 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -1846,6 +1846,9 @@ "additionalProperties": { "additionalProperties": false, "properties": { + "claims_fresh": { + "type": "integer" + }, "decode_failures": { "type": "integer" }, @@ -1859,7 +1862,8 @@ "required": [ "probe_answers", "observed_edits", - "decode_failures" + "decode_failures", + "claims_fresh" ], "type": "object" }, diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index ced823872..9d35b0e8f 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -1833,6 +1833,9 @@ "additionalProperties": { "additionalProperties": false, "properties": { + "claims_fresh": { + "type": "integer" + }, "decode_failures": { "type": "integer" }, @@ -1846,7 +1849,8 @@ "required": [ "probe_answers", "observed_edits", - "decode_failures" + "decode_failures", + "claims_fresh" ], "type": "object" }, From b677e777fd4a62d1679c07a850ed93e66acd5b8b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:21:40 +0100 Subject: [PATCH 076/138] fix(bench): bind CLI gate probes and retain shipped baseline evidence --- bench/check-budgets/edit_loop.go | 40 ++++++++- bench/check-budgets/edit_loop_command_test.go | 89 +++++++++++++++++++ bench/load/edit_gate_modes_test.go | 28 ++++++ bench/load/edit_output_decode.go | 3 + bench/load/edit_probe_cli.go | 8 +- bench/load/edit_probe_cli_test.go | 45 ++++++++++ 6 files changed, 210 insertions(+), 3 deletions(-) diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index 578151c11..50871fa64 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -234,7 +234,7 @@ func decodeEditLoopWire(phase phase) (editLoopWire, error) { } func assessEditLoopRow(b budget, dataset dataset) editLoopRow { - row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit, Baseline: editLoopBaseline(b, dataset)} values := make([]float64, 0, dataset.config.repeats(b.Scenario)) verdict := -1 for repeat := 1; repeat <= dataset.config.repeats(b.Scenario); repeat++ { @@ -275,6 +275,10 @@ func assessEditLoopRow(b budget, dataset dataset) editLoopRow { value = slices.Min(values) } row.Value = &value + if row.Baseline != nil { + delta := value - *row.Baseline + row.Delta = &delta + } row.State = "FAIL" if verdict == 1 { row.State = "PASS" @@ -282,6 +286,40 @@ func assessEditLoopRow(b budget, dataset dataset) editLoopRow { return row } +// The WAL policy explicitly adopts each shipped release peak as the p95 +// baseline. Other rows use actual matched shipped reports when available; +// the historical development-machine ACK numbers are not reference-host data. +func editLoopBaseline(b budget, d dataset) *float64 { + if b.Metric == "wal_p95_mib" { + if value, exists := map[string]float64{"loadedit-1": 1007, "loadedit-4": 1059, "loadedit-8": 960, "multirepoedit-6": 760}[b.Scenario]; exists { + return &value + } + } + values := make([]float64, 0, d.config.repeats(b.Scenario)) + for repeat := 1; repeat <= d.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range d.runs { + if observation.input.Role != "tag" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + value, err := measureEditLoopRow(observation.data.Phases[0], b.Metric) + if err != nil { + return nil + } + values = append(values, value) + found = true + } + if !found { + return nil + } + } + value, err := median(values) + if err != nil { + return nil + } + return &value +} + func editLoopCompare(value, target float64, compare string) bool { switch compare { case "le": diff --git a/bench/check-budgets/edit_loop_command_test.go b/bench/check-budgets/edit_loop_command_test.go index 76ce2510c..41f9670d9 100644 --- a/bench/check-budgets/edit_loop_command_test.go +++ b/bench/check-budgets/edit_loop_command_test.go @@ -2,6 +2,9 @@ package main import ( "bytes" + "encoding/json" + "fmt" + "os" "path/filepath" "strings" "testing" @@ -26,3 +29,89 @@ func TestEditLoopGateRetainsEveryRowWhenEvidenceIsMissing(t *testing.T) { t.Fatal("unobserved row became a numeric pass") } } + +func TestEditLoopCommandRetainsActualRowsAndSeededBreaches(t *testing.T) { + for _, variant := range []string{"complete", "ack_wait", "closed_gate", "repeat_disagreement"} { + t.Run(variant, func(t *testing.T) { + dir := t.TempDir() + config := inputs{Version: 1, Repeats: 3, CandidateRevision: strings.Repeat("a", 40), MainRevision: strings.Repeat("d", 40), TagRevision: shippedRevision, Hosts: map[string]string{"reference_linux": "synthetic-host"}} + write := func(path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, path), data, 0o600); err != nil { + t.Fatal(err) + } + } + for repeat := 1; repeat <= 3; repeat++ { + r := editLoopEvidenceFixture(t) + r.RunID = fmt.Sprintf("synthetic-edit-%d", repeat) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + compact := raw["output_modes"].(map[string]any)["compact"].(map[string]any) + compact["claims_fresh_observable"], compact["claims_fresh"] = false, 0 + if variant == "ack_wait" || variant == "repeat_disagreement" && repeat == 3 { + for _, value := range raw["edits"].([]any) { + value.(map[string]any)["t_ack_ms"] = map[string]any{"value": 3000, "unit": "ms", "reason": ""} + } + } + if variant == "closed_gate" { + r.Phases[0].Metrics["reclaim_gate_closures"] = metric{Value: pointer(1), Unit: "closures"} + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + name := fmt.Sprintf("repeat-%d.json", repeat) + write(name, r) + config.Runs = append(config.Runs, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4", Repeat: repeat, Report: name}) + } + write("inputs.json", config) + var out, diagnostics bytes.Buffer + if exit := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(dir, "inputs.json"), "--out", filepath.Join(dir, "result.json")}, &out, &diagnostics); exit != 1 { + t.Fatalf("unobservable compact cell must stay explicit/nonpassing, exit%d: %s", exit, diagnostics.String()) + } + data, err := os.ReadFile(filepath.Join(dir, "result.json")) + if err != nil { + t.Fatal(err) + } + var result struct { + Rows []editLoopRow `json:"rows"` + } + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + rows := map[string]editLoopRow{} + for _, row := range result.Rows { + rows[row.Metric] = row + } + if rows["silent_stale_compact"].State != "UNAVAILABLE" || rows["silent_stale_compact"].Value != nil { + t.Error("unobservable compact freshness became numeric pass") + } + if row := rows["wal_p95_mib"]; row.State != "PASS" || row.Value == nil || *row.Value != 32 || row.Baseline == nil || *row.Baseline != 1059 || row.Delta == nil || *row.Delta != -1027 { + t.Errorf("fixed shipped WAL baseline/delta missing: %+v", row) + } + wantAck, wantGate := "PASS", "PASS" + if variant == "ack_wait" || variant == "repeat_disagreement" { + wantAck = "FAIL" + } + if variant == "closed_gate" { + wantGate = "FAIL" + } + if row := rows["edit_ack_p95"]; row.State != wantAck { + t.Errorf("ACK row %+v, want%s; %s", row, wantAck, out.String()) + } + if row := rows["read_gate_closures"]; row.State != wantGate { + t.Errorf("read gate row %+v, want%s", row, wantGate) + } + if rows["silent_stale_json"].State != "PASS" || rows["readback_correct"].State != "PASS" { + t.Errorf("actual complete witnesses lost: %s", out.String()) + } + }) + } +} diff --git a/bench/load/edit_gate_modes_test.go b/bench/load/edit_gate_modes_test.go index 920ad2acd..97af39d6a 100644 --- a/bench/load/edit_gate_modes_test.go +++ b/bench/load/edit_gate_modes_test.go @@ -148,3 +148,31 @@ func TestEditGateFacadeRequiresMatchingDispatchEnvelopeAndActualJSON(t *testing. }) } } + +func TestEditGateJSONAndFacadeRefuseNullPayloadWithoutPanic(t *testing.T) { + for _, mode := range []string{"json", "facade"} { + t.Run(mode, func(t *testing.T) { + defer func() { + if value := recover(); value != nil { + t.Errorf("invalid actual response panicked instead of refusing evidence: %v", value) + } + }() + raw := outputWire("null", nil) + if mode == "facade" { + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + envelope["_meta"] = map[string]any{"gortex_facade": map[string]any{"surface_version": "facade-v1", "facade": "read", "operation": "summary", "canonical_tool": "get_file_summary"}} + var err error + raw, err = json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + } + if _, err := decodeEditProbeOutput(raw, mode, "read.summary"); err == nil { + t.Error("null payload counted as a decoded answer") + } + }) + } +} diff --git a/bench/load/edit_output_decode.go b/bench/load/edit_output_decode.go index 52c224072..d1f6bb4f2 100644 --- a/bench/load/edit_output_decode.go +++ b/bench/load/edit_output_decode.go @@ -72,6 +72,9 @@ func decodeEditProbeOutput(raw json.RawMessage, format, operation string) (map[s if err := decoder.Decode(&body); err != nil { return nil, err } + if body == nil { + return nil, errors.New("requested JSON must return an object, not null") + } if facade { body[editProbeFacadeWireKey] = editProbeFacadeWire{operation: operation} } diff --git a/bench/load/edit_probe_cli.go b/bench/load/edit_probe_cli.go index 292e16483..e7d6ecd42 100644 --- a/bench/load/edit_probe_cli.go +++ b/bench/load/edit_probe_cli.go @@ -33,14 +33,18 @@ func (b *editProbeCappedBuffer) Write(data []byte) (int, error) { // The edit ACK, readbacks and receipt status stay MCP JSON; only oracle // requests go through the CLI, with the same lowered facade arguments. func (r *editProbeRuntime) performCLI(ctx context.Context, s *editProbeState, req request, purpose string, stream int) (json.RawMessage, wireCallIdentity, error) { + repo, err := r.repoFor(s.step) + if err != nil { + return nil, wireCallIdentity{}, err + } budget := min(r.options.Deadline, 20*time.Second) callCtx, cancel := context.WithTimeout(ctx, budget) defer cancel() - args, err := json.Marshal(dispatchArgs(req, r.p.Repos[0])) + args, err := json.Marshal(dispatchArgs(req, repo)) if err != nil { return nil, wireCallIdentity{}, err } - argv := []string{"--config", r.p.Config, "call", req.Tool, "--json", string(args), "--format", "json", "--index", r.p.Repos[0].Path, "--quiet"} + argv := []string{"--config", r.p.Config, "call", req.Tool, "--json", string(args), "--format", "json", "--index", repo.Path, "--quiet"} cmd := exec.CommandContext(callCtx, r.p.Binary, argv...) configureOwnedProcess(cmd) cmd.Cancel = func() error { return killOwnedProcess(cmd) } diff --git a/bench/load/edit_probe_cli_test.go b/bench/load/edit_probe_cli_test.go index 0b81777ae..c82507b32 100644 --- a/bench/load/edit_probe_cli_test.go +++ b/bench/load/edit_probe_cli_test.go @@ -177,3 +177,48 @@ func TestEditProbeCLIModeRefusesAbsenceKinds(t *testing.T) { } } } + +func TestEditGateCLIUsesItsAssignedCheckoutAndRejectsUnknownEditor(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(fmt.Sprint(invalid), func(t *testing.T) { + argvFile := filepath.Join(t.TempDir(), "argv.json") + run, state, oracle := editProbeCLIFixture(t, 10*time.Second, "LOAD_EDIT_PROBE_CLI_FIXTURE=reply", "LOAD_EDIT_PROBE_CLI_ARGV="+argvFile, "LOAD_EDIT_PROBE_CLI_STDOUT={}") + defer state.finishRaw() + second := run.p.Repos[0] + second.Path, second.Prefix, second.Corpus.Label = filepath.Join(run.p.Root, "second-clone"), "second", "second" + run.p.Repos = append(run.p.Repos, second) + run.editors = []*socketClient{{cwd: second.Path}} + if invalid { + state.step.Editor = 7 + } + _, _, err := run.performCLI(t.Context(), state, oracle.call, "oracle:summary", 11) + if invalid { + if err == nil { + t.Error("unknown route started a CLI process against the first repository") + } + if _, err := os.Stat(argvFile); !errors.Is(err, os.ErrNotExist) { + t.Error("foreign editor launched a process") + } + return + } + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(argvFile) + if err != nil { + t.Fatal(err) + } + var argv []string + if err := json.Unmarshal(raw, &argv); err != nil { + t.Fatal(err) + } + lowered, err := json.Marshal(dispatchArgs(oracle.call, second)) + if err != nil { + t.Fatal(err) + } + if len(argv) != 11 || argv[9] != second.Path || argv[5] != string(lowered) { + t.Errorf("CLI borrowed another checkout's index/arguments: %q", argv) + } + }) + } +} From 3b64e4708550f39e0e06a53e66ed1f00a16ca422 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:26:48 +0100 Subject: [PATCH 077/138] feat(bench): run edit gates through the owned lane collector --- bench/check-budgets/edit_loop_lane.go | 107 +++++++++++++++++++++ bench/check-budgets/edit_loop_lane_test.go | 84 ++++++++++++++++ bench/check-budgets/lane.go | 52 +++++++--- bench/check-budgets/main.go | 2 +- 4 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 bench/check-budgets/edit_loop_lane.go create mode 100644 bench/check-budgets/edit_loop_lane_test.go diff --git a/bench/check-budgets/edit_loop_lane.go b/bench/check-budgets/edit_loop_lane.go new file mode 100644 index 000000000..5489df2b5 --- /dev/null +++ b/bench/check-budgets/edit_loop_lane.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "path/filepath" + "slices" + "time" +) + +// These are separate repeated smoke runs of one build, each with its own +// <=15m wall limit. The nightly's single-editor Poisson schedule is retained; +// it is not compressed to fit the smoke-only deadline. +func makeEditLoopLanePlan(p policy, c laneConfig) (lanePlan, error) { + plan := lanePlan{Lane: c.Lane, Execution: "edit-loop", HostClass: c.HostClass, Ready: true, PolicySHA256: policyDigest(p), Prerequisites: []string{}} + if c.HostClass != "reference_linux" || c.Lane != "smoke" && c.Lane != "nightly" { + return plan, errors.New("edit gates require the reference Linux host and smoke or nightly lane") + } + scenarios := []string{"loadedit-4"} + if c.Lane == "nightly" { + scenarios = []string{"loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "loadedit-4-largefile"} + } + if c.Load == "" || c.Manifest == "" || c.Builds["candidate"].Binary == "" { + plan.Prerequisites = append(plan.Prerequisites, "pinned runtime configuration and reviewed owned-copy corpus manifest required") + } + for _, scenario := range scenarios { + for repeat := 1; repeat <= max(3, c.Repeats); repeat++ { + task := laneTask{Scenario: scenario, Role: "candidate", Repeat: repeat, Seed: int64(repeat), EditGate: true, EditCount: 100, ProbeModes: []string{"json", "toon", "compact", "facade", "cli"}} + if scenario == "loadedit-4-largefile" { + task.Unavailable = "large-file gate needs actual >=10000-line target admission; a scenario alias is not evidence" + } + plan.Tasks = append(plan.Tasks, task) + } + } + if c.Lane == "nightly" { + plan.Prerequisites = append(plan.Prerequisites, "large-file actual target-line census not yet bound to this lane") + } + plan.Ready = len(plan.Prerequisites) == 0 + return plan, nil +} + +func finishEditLoopLaneEvidence(c laneConfig, p policy, plan lanePlan, out string, stdout io.Writer, receipt laneReceipt, index inputs, failures []error) error { + if err := writeNewJSON(filepath.Join(out, "inputs.json"), index); err != nil { + failures = append(failures, err) + } + d, loadErr := loadInputsChecked(filepath.Join(out, "inputs.json"), validateEditLoopReport) + scenarios := map[string]bool{} + for _, task := range plan.Tasks { + scenarios[task.Scenario] = true + } + rows := []editLoopRow{} + pass := true + for _, b := range p.Budgets { + if b.HostClass != c.HostClass || !scenarios[b.Scenario] || !slices.Contains(b.Lane, "edit-smoke") { + continue + } + row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit} + if loadErr != nil { + row.Reason = loadErr.Error() + } else { + row = assessEditLoopRow(b, d) + } + if row.State != "PASS" { + pass = false + } + rows = append(rows, row) + } + var table bytes.Buffer + if err := printEditLoopRows(io.MultiWriter(stdout, &table), rows); err != nil { + failures = append(failures, err) + } + if err := writeNewJSON(filepath.Join(out, "budget-results.json"), struct { + Pass bool `json:"pass"` + Rows []editLoopRow `json:"rows"` + }{pass, rows}); err != nil { + failures = append(failures, err) + } + if err := writeNewJSON(filepath.Join(out, "budget-table.json"), table.String()); err != nil { + failures = append(failures, err) + } + receipt.BudgetPass = pass + if !pass || len(rows) == 0 { + failures = append(failures, errors.New("edit-loop budgets have failed or unavailable rows")) + } + if !plan.Ready { + failures = append(failures, errors.New("edit-loop prerequisites unavailable; see plan")) + } + receipt.Finished = time.Now().UTC() + receipt.WallSeconds = receipt.Finished.Sub(receipt.Started).Seconds() + err := errors.Join(failures...) + if err != nil { + receipt.Error = err.Error() + } + return errors.Join(err, writeNewJSON(filepath.Join(out, "lane.json"), receipt), laneSummary("Repeated edit-loop runs", receipt)) +} + +func printEditLoopRows(output io.Writer, rows []editLoopRow) error { + fmt.Fprintln(output, "scenario\tmetric\tvalue\tbudget\tunit\tbaseline\tdelta\tstate\treason") + for _, row := range rows { + if _, err := fmt.Fprintf(output, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", row.Scenario, row.Metric, editLoopNumber(row.Value), editLoopNumber(row.Budget), row.Unit, editLoopNumber(row.Baseline), editLoopNumber(row.Delta), row.State, row.Reason); err != nil { + return err + } + } + return nil +} diff --git a/bench/check-budgets/edit_loop_lane_test.go b/bench/check-budgets/edit_loop_lane_test.go new file mode 100644 index 000000000..d5765389a --- /dev/null +++ b/bench/check-budgets/edit_loop_lane_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "bytes" + "encoding/json" + "slices" + "testing" +) + +func TestEditLoopLanePlanKeepsSmokeAndNightlyDenominators(t *testing.T) { + for _, lane := range []string{"smoke", "nightly"} { + t.Run(lane, func(t *testing.T) { + var out, diagnostics bytes.Buffer + exit := run([]string{"edit-plan", "--lane", lane, "--host-class", "reference_linux", "--budgets", "../budgets.yaml"}, &out, &diagnostics) + if exit != 1 { + t.Fatalf("unconfigured planning must retain missing runtime prerequisites, exit%d: %s", exit, diagnostics.String()) + } + var plan struct { + Execution string `json:"execution"` + Ready bool `json:"ready"` + Tasks []struct { + Scenario string `json:"scenario"` + Role string `json:"role"` + Count int `json:"edit_count"` + Modes []string `json:"probe_modes"` + Gate bool `json:"edit_gate"` + } `json:"tasks"` + } + if err := json.Unmarshal(out.Bytes(), &plan); err != nil { + t.Fatalf("no reviewable edit plan: %v: %s", err, out.String()) + } + if plan.Ready || plan.Execution != "edit-loop" { + t.Fatalf("unconfigured execution claims ready or wrong surface: %+v", plan) + } + counts := map[string]int{} + for _, task := range plan.Tasks { + if task.Role != "candidate" || task.Count != 100 || !task.Gate || !slices.Equal(task.Modes, []string{"json", "toon", "compact", "facade", "cli"}) { + t.Errorf("gate task changed actual denominator/surfaces: %+v", task) + } + counts[task.Scenario]++ + } + want := []string{"loadedit-4"} + if lane == "nightly" { + want = []string{"loadedit-1", "loadedit-4", "loadedit-8", "loadedit-16", "loadedit-32", "multirepoedit-6", "loadedit-4-largefile"} + } + if len(counts) != len(want) { + t.Errorf("wrong matrix: %+v", counts) + } + for _, name := range want { + if counts[name] != 3 { + t.Errorf("%s has%d repeats, want3", name, counts[name]) + } + } + }) + } +} + +func TestEditLoopTaskActuallySelectsGateInsteadOfLegacyEditTraffic(t *testing.T) { + for _, scenario := range []string{"loadedit-4", "multirepoedit-6"} { + t.Run(scenario, func(t *testing.T) { + var task laneTask + if err := json.Unmarshal([]byte(`{"scenario":"`+scenario+`","role":"candidate","repeat":1,"edit_gate":true}`), &task); err != nil { + t.Fatal(err) + } + c := fixtureLaneConfig() + c.HostClass = "reference_linux" + args := taskArgs(c, task, "manifest.json", "state", "report.json") + arg := func(name string) string { + for i := range args { + if args[i] == name && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + if arg("--scenario") != "edit-and-probe" || arg("--agents") != "0" || arg("--edit-count") != "100" || !slices.Contains(args, "--edit-gate-modes") { + t.Errorf("actual process still runs legacy edit traffic: %v", args) + } + if scenario == "multirepoedit-6" && (arg("--edit-repos") != "6" || arg("--edit-agents") != "6") { + t.Errorf("six-repository row lost real routing: %v", args) + } + }) + } +} diff --git a/bench/check-budgets/lane.go b/bench/check-budgets/lane.go index 3a5885b75..4108904f1 100644 --- a/bench/check-budgets/lane.go +++ b/bench/check-budgets/lane.go @@ -61,11 +61,14 @@ type laneConfig struct { } type laneTask struct { - Scenario string `json:"scenario"` - Role string `json:"role"` - Repeat int `json:"repeat"` - Seed int64 `json:"seed"` - Unavailable string `json:"unavailable,omitempty"` + EditGate bool `json:"edit_gate,omitempty"` + EditCount int `json:"edit_count,omitempty"` + ProbeModes []string `json:"probe_modes,omitempty"` + Scenario string `json:"scenario"` + Role string `json:"role"` + Repeat int `json:"repeat"` + Seed int64 `json:"seed"` + Unavailable string `json:"unavailable,omitempty"` } type lanePlan struct { @@ -99,7 +102,7 @@ type laneReceipt struct { Smoke *smokeProvenance `json:"smoke_provenance,omitempty"` } -func readLaneConfig(path string) (laneConfig, error) { +func readLaneConfig(path string, editGate ...bool) (laneConfig, error) { var c laneConfig data, err := os.ReadFile(path) if err != nil { @@ -162,7 +165,7 @@ func readLaneConfig(path string) (laneConfig, error) { if c.MainArtifact != "" { c.MainArtifact = resolve(c.MainArtifact) } - if c.Lane == "smoke" && c.MainArtifact == "" { + if c.Lane == "smoke" && c.MainArtifact == "" && (len(editGate) == 0 || !editGate[0]) { return c, errors.New("smoke requires the verified exact-main artifact bundle") } for role, build := range c.Builds { @@ -240,6 +243,8 @@ func policyDigest(p policy) string { } func laneCommand(command string, args []string, stdout, stderr io.Writer) int { + editGate := strings.HasPrefix(command, "edit-") + command = strings.TrimPrefix(command, "edit-") f := flag.NewFlagSet(command, flag.ContinueOnError) f.SetOutput(stderr) config := f.String("config", "", "strict pinned lane config") @@ -270,7 +275,7 @@ func laneCommand(command string, args []string, stdout, stderr io.Writer) int { c := laneConfig{Lane: *lane, HostClass: *host, Repeats: 3} var err error if *config != "" { - c, err = readLaneConfig(*config) + c, err = readLaneConfig(*config, editGate) } else if !slices.Contains([]string{"smoke", "nightly", "release"}, *lane) { err = errors.New("unknown lane") } @@ -328,7 +333,12 @@ func laneCommand(command string, args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, err) return 1 } - plan, err := makeLanePlan(p, c) + var plan lanePlan + if editGate { + plan, err = makeEditLoopLanePlan(p, c) + } else { + plan, err = makeLanePlan(p, c) + } if err != nil { fmt.Fprintln(stderr, err) return 1 @@ -429,12 +439,18 @@ func taskManifest(c laneConfig, task laneTask, root string) (string, error) { func taskArgs(c laneConfig, task laneTask, manifest, scratch, report string) []string { spec := scenarioSpecs[task.Scenario] + if task.EditGate { + spec.wire, spec.agents = "edit-and-probe", 0 + } host := "native" if c.HostClass == "reference_linux" { host = "reference" } build := c.Builds[task.Role] args := []string{"run", "--binary", build.Binary, "--git-sha", build.Revision, "--manifest", manifest, "--scratch", scratch, "--out", report, "--host", host, "--profile", "agent", "--scenario", spec.wire, "--agents", fmt.Sprint(spec.agents), "--edit-agents", fmt.Sprint(spec.editors), "--worktrees", fmt.Sprint(spec.worktrees), "--seed", fmt.Sprint(task.Seed), "--duration", c.Duration, "--deadline", c.Deadline, "--warm-timeout", c.WarmTimeout, "--stop-timeout", c.StopTimeout, "--sweep-repetitions", fmt.Sprint(c.SweepRepetitions), "--sweep-duration", c.SweepDuration} + if task.EditGate { + args = append(args, "--edit-gate-modes", "--edit-count", "100", "--edit-repos", fmt.Sprint(spec.repos)) + } if c.Lane == "smoke" && spec.wire == "read" && c.PRSmokeDrain != "" { args = append(args, "--pr-smoke-drain", c.PRSmokeDrain) } @@ -463,7 +479,7 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out if server, repo, id := os.Getenv("GITHUB_SERVER_URL"), os.Getenv("GITHUB_REPOSITORY"), os.Getenv("GITHUB_RUN_ID"); server != "" && repo != "" && id != "" { receipt.Workflow.RunURL = server + "/" + repo + "/actions/runs/" + id } - if c.Lane == "smoke" { + if c.Lane == "smoke" && plan.Execution != "edit-loop" { proof, err := prepareSmokeProvenance(c, receipt.Workflow) if err != nil { receipt.Error = err.Error() @@ -510,7 +526,18 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out var log *os.File log, err = os.OpenFile(filepath.Join(root, "client.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { - err = runProcess(ctx, c.Load, taskArgs(c, task, manifest, filepath.Join(root, "state"), reportPath), log) + taskCtx := ctx + cancel := func() {} + if task.EditGate && c.Lane == "smoke" { + // Interrupt the signal-aware client at 13m, preserving its + // independent <=2m owned cleanup inside the 15m per-run cap. + taskCtx, cancel = context.WithDeadline(ctx, tr.Started.Add(13*time.Minute)) + } + err = runProcess(taskCtx, c.Load, taskArgs(c, task, manifest, filepath.Join(root, "state"), reportPath), log) + cancel() + if task.EditGate && c.Lane == "smoke" && time.Since(tr.Started) > 15*time.Minute { + err = errors.Join(err, errors.New("edit smoke run exceeded 15 minutes including startup and cleanup")) + } err = errors.Join(err, log.Close()) } } @@ -523,6 +550,9 @@ func collectLane(ctx context.Context, c laneConfig, p policy, plan lanePlan, out tr.Finished = time.Now().UTC() receipt.Tasks = append(receipt.Tasks, tr) } + if plan.Execution == "edit-loop" { + return finishEditLoopLaneEvidence(c, p, plan, out, stdout, receipt, index, failures) + } return finishLaneEvidence(c, p, plan, out, stdout, receipt, index, failures) } diff --git a/bench/check-budgets/main.go b/bench/check-budgets/main.go index 20dd29f16..2238a65c6 100644 --- a/bench/check-budgets/main.go +++ b/bench/check-budgets/main.go @@ -21,7 +21,7 @@ func run(args []string, stdout, stderr io.Writer) int { switch args[0] { case "edit-loop": return editLoopCommand(args[1:], stdout, stderr) - case "plan", "collect": + case "plan", "collect", "edit-plan", "edit-collect": return laneCommand(args[0], args[1:], stdout, stderr) case "history": return historyCommand(args[1:], stdout, stderr) From 0eab7c574cebf2277b73bd2ad945bdfc180034d7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:32:30 +0100 Subject: [PATCH 078/138] feat(bench): qualify compact gates only with actual semantic witnesses --- bench/check-budgets/edit_loop.go | 79 ++++++++++++++- bench/check-budgets/edit_loop_command_test.go | 17 +++- .../check-budgets/edit_loop_evidence_test.go | 55 +++++++++++ bench/check-budgets/edit_loop_lane.go | 2 +- bench/load/edit_gate_runtime_test.go | 78 +++++++++++++++ bench/load/edit_probe_output.go | 96 +++++++++++++------ bench/load/edit_probe_run.go | 3 +- bench/load/testdata/paired-v1.schema.json | 31 ++++++ bench/load/testdata/report-v1.schema.json | 31 ++++++ 9 files changed, 354 insertions(+), 38 deletions(-) diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index 50871fa64..5804925b6 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -55,10 +55,15 @@ type editLoopWire struct { } `json:"edits"` Modes map[string]struct { Tools map[string]struct { - ObservedEdits *int `json:"observed_edits"` - Answers int `json:"probe_answers"` - DecodeFailures int `json:"decode_failures"` - ClaimsFresh *int `json:"claims_fresh"` + State string `json:"state"` + Reason string `json:"reason"` + CorrectEdits *int `json:"correct_edits"` + RefusedEdits *int `json:"refused_edits"` + FallbackAnswers *int `json:"fallback_answers"` + ObservedEdits *int `json:"observed_edits"` + Answers int `json:"probe_answers"` + DecodeFailures int `json:"decode_failures"` + ClaimsFresh *int `json:"claims_fresh"` } `json:"tools"` ObservedEdits *int `json:"observed_edits"` ClaimsFresh *int `json:"claims_fresh"` @@ -113,7 +118,7 @@ func editLoopCommand(args []string, stdout, stderr io.Writer) int { } else { row = assessEditLoopRow(b, dataset) } - if row.State != "PASS" { + if editLoopRowBlocks(row) { pass = false } rows = append(rows, row) @@ -235,6 +240,9 @@ func decodeEditLoopWire(phase phase) (editLoopWire, error) { func assessEditLoopRow(b budget, dataset dataset) editLoopRow { row := editLoopRow{Metric: b.Metric, Scenario: b.Scenario, State: "UNAVAILABLE", Budget: b.Target, Unit: b.Unit, Baseline: editLoopBaseline(b, dataset)} + if b.Metric == "silent_stale_compact" { + return assessCompactEditLoopRow(row, b, dataset) + } values := make([]float64, 0, dataset.config.repeats(b.Scenario)) verdict := -1 for repeat := 1; repeat <= dataset.config.repeats(b.Scenario); repeat++ { @@ -320,6 +328,67 @@ func editLoopBaseline(b budget, d dataset) *float64 { return &value } +// Only the explicitly accepted compact metadata contract is qualified. Every +// supported compact tool still proves decoded post-ACK content for every +// assigned edit; unsupported operations prove the harness's format refusal +// and actual JSON fallback without receiving any compact-format credit. +func assessCompactEditLoopRow(row editLoopRow, b budget, d dataset) editLoopRow { + if d.config.repeats(b.Scenario) < 3 { + row.Reason = "at least three actual repeats required" + return row + } + for repeat := 1; repeat <= d.config.repeats(b.Scenario); repeat++ { + found := false + for _, observation := range d.runs { + if observation.input.Role != "candidate" || observation.input.HostClass != b.HostClass || observation.input.Scenario != b.Scenario || observation.input.Repeat != repeat { + continue + } + found = true + wire, err := decodeEditLoopWire(observation.data.Phases[0]) + if err != nil { + row.Reason = err.Error() + return row + } + expected := 0 + for _, record := range wire.Edits { + if record.Mode == "compact" { + expected++ + } + } + mode, exists := wire.Modes["compact"] + if !exists || expected == 0 || mode.ObservedEdits == nil || *mode.ObservedEdits != expected || mode.Answers < expected || mode.DecodeFailures != 0 || mode.Observable || mode.ClaimsFresh == nil || *mode.ClaimsFresh != 0 || mode.Stale.Value != nil || mode.Stale.Reason == "" { + row.Reason = "compact actual assigned-edit/decoder proof missing or contradicts known unobservable contract" + return row + } + for _, name := range []string{"read.summary", "search.symbols", "relations.callers", "trace.call_chain"} { + tool, exists := mode.Tools[name] + if !exists || tool.ObservedEdits == nil || *tool.ObservedEdits != expected || tool.CorrectEdits == nil || *tool.CorrectEdits != expected || tool.Answers < expected || tool.DecodeFailures != 0 { + row.Reason = "compact supported tool lacks post-ACK semantic witness: " + name + return row + } + } + for _, name := range []string{"read.source", "read.editing_context", "search.text"} { + tool, exists := mode.Tools[name] + if !exists || tool.State != "UNSUPPORTED" || tool.Reason == "" || tool.RefusedEdits == nil || *tool.RefusedEdits != expected || tool.FallbackAnswers == nil || *tool.FallbackAnswers < expected || tool.ObservedEdits == nil || *tool.ObservedEdits != 0 || tool.DecodeFailures != 0 { + row.Reason = "compact unsupported operation lacks explicit format refusal and actual fallback proof: " + name + return row + } + } + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + row.State = "UNOBSERVABLE" + row.Reason = "compact freshness metadata is unobservable under the accepted contract; actual supported content/tool and unsupported-refusal proofs passed; no numeric silent-stale claim" + return row +} + +func editLoopRowBlocks(row editLoopRow) bool { + return row.State != "PASS" && !(row.Metric == "silent_stale_compact" && row.State == "UNOBSERVABLE" && row.Value == nil) +} + func editLoopCompare(value, target float64, compare string) bool { switch compare { case "le": diff --git a/bench/check-budgets/edit_loop_command_test.go b/bench/check-budgets/edit_loop_command_test.go index 41f9670d9..f7dc3461f 100644 --- a/bench/check-budgets/edit_loop_command_test.go +++ b/bench/check-budgets/edit_loop_command_test.go @@ -54,6 +54,17 @@ func TestEditLoopCommandRetainsActualRowsAndSeededBreaches(t *testing.T) { } compact := raw["output_modes"].(map[string]any)["compact"].(map[string]any) compact["claims_fresh_observable"], compact["claims_fresh"] = false, 0 + compact["stale_claimed_fresh"] = map[string]any{"value": nil, "unit": "answers", "reason": "compact freshness metadata unobservable"} + tools := compact["tools"].(map[string]any) + for name, value := range tools { + tool := value.(map[string]any) + if name == "read.source" || name == "read.editing_context" || name == "search.text" { + tool["state"], tool["reason"], tool["refused_edits"], tool["fallback_answers"], tool["observed_edits"] = "UNSUPPORTED", "harness compact-format refusal; actual JSON fallback retained", 20, 20, 0 + } else { + tool["correct_edits"] = 20 + } + } + if variant == "ack_wait" || variant == "repeat_disagreement" && repeat == 3 { for _, value := range raw["edits"].([]any) { value.(map[string]any)["t_ack_ms"] = map[string]any{"value": 3000, "unit": "ms", "reason": ""} @@ -73,8 +84,8 @@ func TestEditLoopCommandRetainsActualRowsAndSeededBreaches(t *testing.T) { } write("inputs.json", config) var out, diagnostics bytes.Buffer - if exit := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(dir, "inputs.json"), "--out", filepath.Join(dir, "result.json")}, &out, &diagnostics); exit != 1 { - t.Fatalf("unobservable compact cell must stay explicit/nonpassing, exit%d: %s", exit, diagnostics.String()) + if exit := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "loadedit-4", "--inputs", filepath.Join(dir, "inputs.json"), "--out", filepath.Join(dir, "result.json")}, &out, &diagnostics); exit != map[bool]int{true: 0, false: 1}[variant == "complete"] { + t.Fatalf("qualified compact cell must stay nonnumeric while blocking breaches fail, exit%d: %s", exit, diagnostics.String()) } data, err := os.ReadFile(filepath.Join(dir, "result.json")) if err != nil { @@ -90,7 +101,7 @@ func TestEditLoopCommandRetainsActualRowsAndSeededBreaches(t *testing.T) { for _, row := range result.Rows { rows[row.Metric] = row } - if rows["silent_stale_compact"].State != "UNAVAILABLE" || rows["silent_stale_compact"].Value != nil { + if rows["silent_stale_compact"].State != "UNOBSERVABLE" || rows["silent_stale_compact"].Value != nil { t.Error("unobservable compact freshness became numeric pass") } if row := rows["wal_p95_mib"]; row.State != "PASS" || row.Value == nil || *row.Value != 32 || row.Baseline == nil || *row.Baseline != 1059 || row.Delta == nil || *row.Delta != -1027 { diff --git a/bench/check-budgets/edit_loop_evidence_test.go b/bench/check-budgets/edit_loop_evidence_test.go index 28668aa87..6794ea7f0 100644 --- a/bench/check-budgets/edit_loop_evidence_test.go +++ b/bench/check-budgets/edit_loop_evidence_test.go @@ -193,3 +193,58 @@ func TestEditLoopNightlySingleEditorRetainsItsOfferedSchedule(t *testing.T) { t.Fatalf("nightly's 100 edits at the original 15-second single-editor cadence got an invented smoke deadline: %v", err) } } + +func TestEditLoopCompactQualificationNeverWaivesMissingProof(t *testing.T) { + for _, variant := range []string{"complete", "missing_supported_tool", "one_correct_edit", "decode_failure", "missing_refusal", "missing_fallback", "untested_unsupported"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + mode := raw["output_modes"].(map[string]any)["compact"].(map[string]any) + mode["claims_fresh_observable"], mode["claims_fresh"] = false, 0 + mode["stale_claimed_fresh"] = map[string]any{"value": nil, "unit": "answers", "reason": "compact freshness metadata unobservable"} + tools := mode["tools"].(map[string]any) + for name, value := range tools { + tool := value.(map[string]any) + if name == "read.source" || name == "read.editing_context" || name == "search.text" { + tool["state"], tool["reason"], tool["refused_edits"], tool["fallback_answers"], tool["observed_edits"] = "UNSUPPORTED", "harness compact-format refusal; actual JSON fallback retained", 20, 20, 0 + } else { + tool["correct_edits"] = 20 + } + } + switch variant { + case "missing_supported_tool": + delete(tools, "read.summary") + case "one_correct_edit": + tools["read.summary"].(map[string]any)["correct_edits"] = 1 + case "decode_failure": + tools["read.summary"].(map[string]any)["decode_failures"] = 1 + case "missing_refusal": + delete(tools["read.source"].(map[string]any), "refused_edits") + case "missing_fallback": + delete(tools["read.source"].(map[string]any), "fallback_answers") + case "untested_unsupported": + tools["read.source"].(map[string]any)["refused_edits"] = 0 + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + d := dataset{config: inputs{Repeats: 3}} + for repeat := 1; repeat <= 3; repeat++ { + d.runs = append(d.runs, observation{input: runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4", Repeat: repeat}, data: r}) + } + row := assessEditLoopRow(budget{Metric: "silent_stale_compact", Scenario: "loadedit-4", HostClass: "reference_linux", Target: pointer(0), Compare: "eq", Unit: "count"}, d) + if variant == "complete" { + if row.State != "UNOBSERVABLE" || row.Value != nil || row.Reason == "" { + t.Errorf("qualified compact needs explicit nonnumeric reason: %+v", row) + } + } else if row.State == "PASS" || row.State == "UNOBSERVABLE" || row.Value != nil { + t.Errorf("missing compact proof qualified: %+v", row) + } + }) + } +} diff --git a/bench/check-budgets/edit_loop_lane.go b/bench/check-budgets/edit_loop_lane.go index 5489df2b5..2c315a534 100644 --- a/bench/check-budgets/edit_loop_lane.go +++ b/bench/check-budgets/edit_loop_lane.go @@ -62,7 +62,7 @@ func finishEditLoopLaneEvidence(c laneConfig, p policy, plan lanePlan, out strin } else { row = assessEditLoopRow(b, d) } - if row.State != "PASS" { + if editLoopRowBlocks(row) { pass = false } rows = append(rows, row) diff --git a/bench/load/edit_gate_runtime_test.go b/bench/load/edit_gate_runtime_test.go index c038a76ab..654090524 100644 --- a/bench/load/edit_gate_runtime_test.go +++ b/bench/load/edit_gate_runtime_test.go @@ -262,6 +262,9 @@ func TestEditGateModeCoverageCountsDistinctAssignedEditsAndActualTools(t *testin defer state.finishRaw() } now := time.Now() + for _, state := range run.states { + state.started, state.acknowledged = now.Add(-2*time.Millisecond), now.Add(-time.Millisecond) + } identity := wireCallIdentity{WriteStarted: now, Finished: now.Add(time.Microsecond)} summary := editProbeOracle{name: "summary", call: request{Tool: "read", Operation: "summary"}} for range 2 { @@ -304,3 +307,78 @@ func TestEditGateModeCoverageCountsDistinctAssignedEditsAndActualTools(t *testin t.Errorf("facade mode omitted entirely: %s", data) } } + +func TestEditGateCompactCoverageRequiresPostACKSemanticAndRefusalProof(t *testing.T) { + for _, variant := range []string{"pre_ack", "wrong_content", "correct_content", "decode_failure", "unsupported_fallback"} { + t.Run(variant, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = "compact" + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + state := run.states[0] + defer state.finishRaw() + now := time.Now() + state.started, state.acknowledged = now.Add(-3*time.Millisecond), now.Add(-time.Millisecond) + sent := now + if variant == "pre_ack" { + sent = now.Add(-2 * time.Millisecond) + } + oracle := editProbeOracle{name: "summary", call: editProbeWithOutput(request{Tool: "read", Operation: "summary"}, "compact")} + row := "function " + state.step.Marker + " fixture/" + c.File + ":5\n" + raw := outputWire(row, nil) + if variant == "wrong_content" { + raw = outputWire("function Wrong fixture/"+c.File+":5\n", nil) + } + if variant == "decode_failure" { + raw = outputWire(`{"fresh":true}`, nil) + } + if variant == "unsupported_fallback" { + oracle = editProbeOracle{name: "source", call: request{Tool: "read", Operation: "source"}} + raw = outputWire(`{}`, nil) + } + if err := run.observeProbe(state, oracle, sent, raw, wireCallIdentity{WriteStarted: sent, Finished: sent.Add(time.Microsecond)}, nil, false); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(editProbeOutputCoverage("gate", run.modeCounts)) + if err != nil { + t.Fatal(err) + } + var modes map[string]struct { + Tools map[string]struct { + Correct *int `json:"correct_edits"` + Refused *int `json:"refused_edits"` + Fallback *int `json:"fallback_answers"` + State string `json:"state"` + } `json:"tools"` + } + if err := json.Unmarshal(data, &modes); err != nil { + t.Fatal(err) + } + if variant == "unsupported_fallback" { + tool := modes["compact"].Tools["read.source"] + if tool.State != "UNSUPPORTED" || tool.Refused == nil || *tool.Refused != 1 || tool.Fallback == nil || *tool.Fallback != 1 { + t.Errorf("actual unsupported fallback/refusal was lost: %s", data) + } + return + } + tool := modes["compact"].Tools["read.summary"] + want := 0 + if variant == "correct_content" { + want = 1 + } + if tool.Correct == nil || *tool.Correct != want { + t.Errorf("compact semantic witness borrowed pre-ACK, wrong or undecoded content: %s", data) + } + }) + } +} diff --git a/bench/load/edit_probe_output.go b/bench/load/edit_probe_output.go index 57e61556a..3f0ca19b7 100644 --- a/bench/load/edit_probe_output.go +++ b/bench/load/edit_probe_output.go @@ -26,10 +26,15 @@ var editProbeOutputModes = []string{"json", "toon", "compact", "cli", "gcx"} type editProbeModes map[string]editProbeModeCoverage type editProbeToolCoverage struct { - ProbeAnswers int `json:"probe_answers"` - ObservedEdits int `json:"observed_edits"` - DecodeFailures int `json:"decode_failures"` - ClaimsFresh int `json:"claims_fresh"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + CorrectEdits int `json:"correct_edits"` + RefusedEdits *int `json:"refused_edits,omitempty"` + FallbackAnswers *int `json:"fallback_answers,omitempty"` + ProbeAnswers int `json:"probe_answers"` + ObservedEdits int `json:"observed_edits"` + DecodeFailures int `json:"decode_failures"` + ClaimsFresh int `json:"claims_fresh"` } type editProbeModeCoverage struct { @@ -57,8 +62,9 @@ type editProbeGateCounts struct { } type editProbeGateToolCounts struct { - answers, decodeFailures, claimsFresh int - edits map[int]bool + answers, decodeFailures, claimsFresh, fallbackAnswers int + correct, refused map[int]bool + edits map[int]bool } // Answers count by each oracle request's actual output mode. A mode without @@ -83,10 +89,26 @@ func editProbeOutputCoverage(selected string, counts map[string]editProbeModeCou if selected == "gate" { observed := 0 coverage.Tools = map[string]editProbeToolCoverage{} + for _, tool := range []string{"read.source", "read.summary", "read.editing_context", "search.symbols", "search.text", "relations.callers", "trace.call_chain"} { + row := editProbeToolCoverage{State: "MISSING"} + if mode == "compact" && !editProbeCompactOperations[tool] { + zero := 0 + row.State, row.Reason, row.RefusedEdits, row.FallbackAnswers = "UNSUPPORTED", "harness compact-format refusal: operation has no compact producer; actual JSON fallback must be witnessed", &zero, &zero + } + coverage.Tools[tool] = row + } if gate != nil { observed = len(gate.edits) for tool, count := range gate.tools { - coverage.Tools[tool] = editProbeToolCoverage{ProbeAnswers: count.answers, ObservedEdits: len(count.edits), DecodeFailures: count.decodeFailures, ClaimsFresh: count.claimsFresh} + row := coverage.Tools[tool] + row.ProbeAnswers, row.ObservedEdits, row.DecodeFailures, row.ClaimsFresh, row.CorrectEdits = count.answers, len(count.edits), count.decodeFailures, count.claimsFresh, len(count.correct) + if row.State == "UNSUPPORTED" { + refused, fallback := len(count.refused), count.fallbackAnswers + row.RefusedEdits, row.FallbackAnswers = &refused, &fallback + } else if row.ObservedEdits > 0 { + row.State = "OBSERVED" + } + coverage.Tools[tool] = row } } coverage.ObservedEdits = &observed @@ -160,40 +182,58 @@ func (r *editProbeRuntime) countMode(mode string, answered, decoded, fresh, stal // Gate witnesses belong to the record's assigned surface. A real JSON // fallback remains counted as JSON traffic, but cannot lend edit coverage or // freshness claims to either another record's JSON cell or its compact cell. -func (r *editProbeRuntime) countGateMode(step editProbeStep, mode, tool string, answered, decoded, fresh, stale, precommit bool) { - if !r.plan.GateModes || mode != step.ProbeMode || !answered { +func (r *editProbeRuntime) countGateMode(step editProbeStep, mode, tool string, answered, decoded, fresh, stale, precommit, correct, postACK bool) { + if !r.plan.GateModes || !answered { + return + } + fallback := step.ProbeMode == "compact" && mode == "json" && !editProbeCompactOperations[tool] + if mode != step.ProbeMode && !fallback { return } r.mu.Lock() defer r.mu.Unlock() - count := r.modeCounts[mode] + count := r.modeCounts[step.ProbeMode] if count.gate == nil { count.gate = &editProbeGateCounts{edits: map[int]bool{}, tools: map[string]*editProbeGateToolCounts{}} } gate := count.gate - gate.answers++ if gate.tools[tool] == nil { - gate.tools[tool] = &editProbeGateToolCounts{edits: map[int]bool{}} + gate.tools[tool] = &editProbeGateToolCounts{edits: map[int]bool{}, correct: map[int]bool{}, refused: map[int]bool{}} } toolCount := gate.tools[tool] - toolCount.answers++ - if decoded { - gate.edits[step.Ordinal], toolCount.edits[step.Ordinal] = true, true + if fallback { + toolCount.fallbackAnswers++ + if decoded && postACK { + toolCount.refused[step.Ordinal] = true + } + if !decoded { + toolCount.decodeFailures++ + gate.decodeFailures++ + } } else { - gate.decodeFailures++ - toolCount.decodeFailures++ - } - if fresh { - toolCount.claimsFresh++ - gate.claimsFresh++ - } - if stale { - gate.stale++ - } - if precommit { - gate.precommit++ + gate.answers++ + toolCount.answers++ + if decoded && postACK { + gate.edits[step.Ordinal], toolCount.edits[step.Ordinal] = true, true + if correct { + toolCount.correct[step.Ordinal] = true + } + } else if !decoded { + gate.decodeFailures++ + toolCount.decodeFailures++ + } + if fresh && postACK { + toolCount.claimsFresh++ + gate.claimsFresh++ + } + if stale { + gate.stale++ + } + if precommit { + gate.precommit++ + } } - r.modeCounts[mode] = count + r.modeCounts[step.ProbeMode] = count } func editProbeRequestOutput(req request) string { diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index c467e398f..aa33c6369 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -639,6 +639,7 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl fresh := decodeErr == nil && editProbeClaimsFresh(body) stale, precommit := false, false s.mu.Lock() + postACK := !s.acknowledged.IsZero() && !sent.Before(s.acknowledged) if expectedAbsence { s.record.ExpectedRefusals++ } else if err != nil { @@ -666,7 +667,7 @@ func (r *editProbeRuntime) observeProbe(s *editProbeState, oracle editProbeOracl } s.mu.Unlock() r.countMode(r.probeOutput(oracle, s.step), err == nil || expectedAbsence, err != nil || decodeErr == nil, fresh, stale, precommit) - r.countGateMode(s.step, r.probeOutput(oracle, s.step), oracle.call.name(), err == nil || expectedAbsence, decodeErr == nil, fresh, stale, precommit) + r.countGateMode(s.step, r.probeOutput(oracle, s.step), oracle.call.name(), err == nil || expectedAbsence, decodeErr == nil, fresh, stale, precommit, correct, postACK) return s.emit(map[string]any{"event": "probe", "oracle": oracle.name, "requested_mode": r.stepProbeMode(s.step), "output_mode": r.probeOutput(oracle, s.step), "start_ns": editProbeOffset(sent, r.origin), "end_ns": editProbeOffset(ended, r.origin), "correct": correct, "claims_fresh": fresh, "receipt_observed_pending": pending}) } diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index a9f89b506..9ecf1d4bb 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -1849,17 +1849,48 @@ "claims_fresh": { "type": "integer" }, + "correct_edits": { + "type": "integer" + }, "decode_failures": { "type": "integer" }, + "fallback_answers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "observed_edits": { "type": "integer" }, "probe_answers": { "type": "integer" + }, + "reason": { + "type": "string" + }, + "refused_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string" } }, "required": [ + "state", + "correct_edits", "probe_answers", "observed_edits", "decode_failures", diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index 9d35b0e8f..a3b38cd64 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -1836,17 +1836,48 @@ "claims_fresh": { "type": "integer" }, + "correct_edits": { + "type": "integer" + }, "decode_failures": { "type": "integer" }, + "fallback_answers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "observed_edits": { "type": "integer" }, "probe_answers": { "type": "integer" + }, + "reason": { + "type": "string" + }, + "refused_edits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string" } }, "required": [ + "state", + "correct_edits", "probe_answers", "observed_edits", "decode_failures", From e81fd357b072088aa749875e5090f7d3077f6757 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:37:49 +0100 Subject: [PATCH 079/138] feat(bench): bind edit gate cells to admitted target and route evidence --- bench/check-budgets/edit_loop.go | 85 ++++++++++++++--- .../check-budgets/edit_loop_evidence_test.go | 92 +++++++++++++++++++ bench/check-budgets/edit_loop_lane.go | 6 +- bench/check-budgets/edit_loop_lane_test.go | 5 +- bench/check-budgets/lane.go | 50 ++++++---- bench/load/edit_gate_runtime_test.go | 46 ++++++++++ bench/load/edit_probe_run.go | 18 ++++ bench/load/testdata/paired-v1.schema.json | 10 ++ bench/load/testdata/report-v1.schema.json | 10 ++ 9 files changed, 285 insertions(+), 37 deletions(-) diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index 5804925b6..ff90cfa6c 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -26,26 +26,42 @@ type editLoopRow struct { type editLoopWire struct { Plan struct { - MeanNS *int64 `json:"mean_interarrival_ns"` - Arrival string `json:"arrival"` - GateModes bool `json:"gate_modes"` - Count int `json:"count"` - Editors int `json:"editors"` - Mode string `json:"mode"` - Session string `json:"session_model"` - Steps []struct { + Routes []struct { + ID string `json:"id"` + Repo string `json:"repo"` + Commit string `json:"commit"` + File string `json:"file"` + Kind string `json:"kind"` + Tool string `json:"tool"` + Language string `json:"language"` + Mode string `json:"mode"` + } `json:"routes"` + LinkedWorktrees int `json:"linked_worktrees"` + MeanNS *int64 `json:"mean_interarrival_ns"` + Arrival string `json:"arrival"` + GateModes bool `json:"gate_modes"` + Count int `json:"count"` + Editors int `json:"editors"` + Mode string `json:"mode"` + Session string `json:"session_model"` + Steps []struct { + Editor int `json:"editor"` + Route string `json:"route"` Ordinal int `json:"ordinal"` Mode string `json:"probe_mode"` } `json:"steps"` } `json:"plan"` Edits []struct { - Ordinal int `json:"ordinal"` - Mode string `json:"probe_mode"` - Outcome string `json:"outcome"` - ACK metric `json:"t_ack_ms"` - OneRead metric `json:"t_gate_readback_ms"` - ACKExact *bool `json:"ack_readback_exact"` - Readbacks []struct { + Editor int `json:"editor"` + Route string `json:"route"` + TargetLines *int `json:"admitted_target_lines"` + Ordinal int `json:"ordinal"` + Mode string `json:"probe_mode"` + Outcome string `json:"outcome"` + ACK metric `json:"t_ack_ms"` + OneRead metric `json:"t_gate_readback_ms"` + ACKExact *bool `json:"ack_readback_exact"` + Readbacks []struct { Kind string `json:"kind"` Correct *bool `json:"correct"` Error string `json:"error"` @@ -215,6 +231,45 @@ func validateEditLoopReport(config inputs, input runInput, r report) error { if editors != expected || wire.Plan.Session != session { return errors.New("plan editor population differs from workload") } + if input.Scenario == "loadedit-4-largefile" { + for _, record := range wire.Edits { + if record.TargetLines == nil || *record.TargetLines < 10000 { + return errors.New("large-file row lacks actual >=10000-line admitted target evidence") + } + } + } + if input.Scenario == "multirepoedit-6" { + if len(wire.Plan.Routes) != 6 || len(r.Corpus) != 6 || wire.Plan.LinkedWorktrees != 0 { + return errors.New("six-repository cell requires six independent pinned corpus routes") + } + corpora := map[string]string{} + for _, corpus := range r.Corpus { + label, _ := corpus["label"].(string) + commit, _ := corpus["commit"].(string) + if label == "" || corpora[label] != "" || !revisionPattern.MatchString(commit) { + return errors.New("invalid six-repository corpus pins") + } + corpora[label] = commit + } + seen := map[string]bool{} + for _, route := range wire.Plan.Routes { + if route.ID == "" || seen[route.ID] || route.Repo != route.ID || corpora[route.ID] != route.Commit || route.File == "" || route.Kind != "add_intrafile_call" || route.Tool != "edit.file" || route.Language != "go" || route.Mode != "json" { + return errors.New("six-repository route differs from admitted corpus pin") + } + seen[route.ID] = true + } + used := map[string]bool{} + for i, record := range wire.Edits { + step := wire.Plan.Steps[i] + if record.Editor < 0 || record.Editor >= 6 || step.Editor != record.Editor || record.Route != step.Route || record.Route != wire.Plan.Routes[record.Editor].ID { + return errors.New("edit borrowed a foreign six-repository editor/route") + } + used[record.Route] = true + } + if len(used) != 6 { + return errors.New("six-repository cell did not actually edit all routes") + } + } return nil } diff --git a/bench/check-budgets/edit_loop_evidence_test.go b/bench/check-budgets/edit_loop_evidence_test.go index 6794ea7f0..1188322f3 100644 --- a/bench/check-budgets/edit_loop_evidence_test.go +++ b/bench/check-budgets/edit_loop_evidence_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -248,3 +249,94 @@ func TestEditLoopCompactQualificationNeverWaivesMissingProof(t *testing.T) { }) } } + +func TestEditLoopLargeFileRequiresActualTargetCensus(t *testing.T) { + for _, variant := range []string{"complete", "small", "missing"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + for _, value := range raw["edits"].([]any) { + value.(map[string]any)["admitted_target_lines"] = 10000 + } + first := raw["edits"].([]any)[0].(map[string]any) + if variant == "small" { + first["admitted_target_lines"] = 9999 + } + if variant == "missing" { + delete(first, "admitted_target_lines") + } + data, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe = data + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + err = validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "loadedit-4-largefile"}, r) + if (err == nil) != (variant == "complete") { + t.Errorf("large-file alias accepted without actual target admission, variant%s error%v", variant, err) + } + }) + } +} + +func TestEditLoopSixRepoCellRequiresPinnedRoutedEvidence(t *testing.T) { + for _, variant := range []string{"complete", "missing_routes", "duplicate_route", "foreign_record", "foreign_pin", "one_corpus"} { + t.Run(variant, func(t *testing.T) { + r := editLoopEvidenceFixture(t) + var raw, workload map[string]any + if err := json.Unmarshal(r.Phases[0].EditProbe, &raw); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(r.Workload, &workload); err != nil { + t.Fatal(err) + } + workload["edit_agents"] = 6 + plan := raw["plan"].(map[string]any) + plan["editors"] = 6 + routes := make([]any, 6) + r.Corpus = nil + for i := range 6 { + label := fmt.Sprintf("repo-%d", i) + commit := fmt.Sprintf("%040x", i+1) + routes[i] = map[string]any{"id": label, "repo": label, "commit": commit, "file": "fixture.go", "kind": "add_intrafile_call", "tool": "edit.file", "language": "go", "mode": "json"} + r.Corpus = append(r.Corpus, map[string]any{"label": label, "commit": commit}) + } + plan["routes"] = routes + for i, value := range raw["edits"].([]any) { + record := value.(map[string]any) + step := plan["steps"].([]any)[i].(map[string]any) + label := fmt.Sprintf("repo-%d", i%6) + record["editor"], record["route"], step["editor"], step["route"] = i%6, label, i%6, label + } + switch variant { + case "missing_routes": + delete(plan, "routes") + case "duplicate_route": + routes[1].(map[string]any)["id"] = routes[0].(map[string]any)["id"] + case "foreign_record": + raw["edits"].([]any)[0].(map[string]any)["route"] = "repo-1" + case "foreign_pin": + routes[0].(map[string]any)["commit"] = strings.Repeat("f", 40) + case "one_corpus": + r.Corpus = r.Corpus[:1] + } + var err error + r.Workload, err = json.Marshal(workload) + if err != nil { + t.Fatal(err) + } + r.Phases[0].EditProbe, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + config := inputs{CandidateRevision: strings.Repeat("a", 40), Hosts: map[string]string{"reference_linux": "synthetic-host"}} + err = validateEditLoopReport(config, runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "multirepoedit-6"}, r) + if (err == nil) != (variant == "complete") { + t.Errorf("six-repo alias borrowed unproved routes, variant%s error%v", variant, err) + } + }) + } +} diff --git a/bench/check-budgets/edit_loop_lane.go b/bench/check-budgets/edit_loop_lane.go index 2c315a534..9f95374b5 100644 --- a/bench/check-budgets/edit_loop_lane.go +++ b/bench/check-budgets/edit_loop_lane.go @@ -28,14 +28,14 @@ func makeEditLoopLanePlan(p policy, c laneConfig) (lanePlan, error) { for _, scenario := range scenarios { for repeat := 1; repeat <= max(3, c.Repeats); repeat++ { task := laneTask{Scenario: scenario, Role: "candidate", Repeat: repeat, Seed: int64(repeat), EditGate: true, EditCount: 100, ProbeModes: []string{"json", "toon", "compact", "facade", "cli"}} - if scenario == "loadedit-4-largefile" { + if scenario == "loadedit-4-largefile" && c.LargeFileManifest == "" { task.Unavailable = "large-file gate needs actual >=10000-line target admission; a scenario alias is not evidence" } plan.Tasks = append(plan.Tasks, task) } } - if c.Lane == "nightly" { - plan.Prerequisites = append(plan.Prerequisites, "large-file actual target-line census not yet bound to this lane") + if c.Lane == "nightly" && c.LargeFileManifest == "" { + plan.Prerequisites = append(plan.Prerequisites, "separate large_file_manifest required; actual >=10000-line admission is validated from each report") } plan.Ready = len(plan.Prerequisites) == 0 return plan, nil diff --git a/bench/check-budgets/edit_loop_lane_test.go b/bench/check-budgets/edit_loop_lane_test.go index d5765389a..ae16a1757 100644 --- a/bench/check-budgets/edit_loop_lane_test.go +++ b/bench/check-budgets/edit_loop_lane_test.go @@ -56,7 +56,7 @@ func TestEditLoopLanePlanKeepsSmokeAndNightlyDenominators(t *testing.T) { } func TestEditLoopTaskActuallySelectsGateInsteadOfLegacyEditTraffic(t *testing.T) { - for _, scenario := range []string{"loadedit-4", "multirepoedit-6"} { + for _, scenario := range []string{"loadedit-4", "multirepoedit-6", "loadedit-4-largefile"} { t.Run(scenario, func(t *testing.T) { var task laneTask if err := json.Unmarshal([]byte(`{"scenario":"`+scenario+`","role":"candidate","repeat":1,"edit_gate":true}`), &task); err != nil { @@ -76,6 +76,9 @@ func TestEditLoopTaskActuallySelectsGateInsteadOfLegacyEditTraffic(t *testing.T) if arg("--scenario") != "edit-and-probe" || arg("--agents") != "0" || arg("--edit-count") != "100" || !slices.Contains(args, "--edit-gate-modes") { t.Errorf("actual process still runs legacy edit traffic: %v", args) } + if scenario == "loadedit-4-largefile" && (arg("--edit-repos") != "1" || arg("--edit-agents") != "4") { + t.Errorf("large-file row has no actual4-editor target: %v", args) + } if scenario == "multirepoedit-6" && (arg("--edit-repos") != "6" || arg("--edit-agents") != "6") { t.Errorf("six-repository row lost real routing: %v", args) } diff --git a/bench/check-budgets/lane.go b/bench/check-budgets/lane.go index 4108904f1..f92c94c96 100644 --- a/bench/check-budgets/lane.go +++ b/bench/check-budgets/lane.go @@ -41,23 +41,24 @@ type laneBuild struct { } type laneConfig struct { - Version int `json:"schema_version"` - Lane string `json:"lane"` - HostClass string `json:"host_class"` - Machine string `json:"machine"` - Load string `json:"load_binary"` - Manifest string `json:"manifest"` - MainArtifact string `json:"main_artifact,omitempty"` - Builds map[string]laneBuild `json:"builds"` - Repeats int `json:"repeats"` - Duration string `json:"duration"` - Deadline string `json:"deadline"` - WarmTimeout string `json:"warm_timeout"` - StopTimeout string `json:"stop_timeout"` - SweepDuration string `json:"sweep_duration"` - SweepRepetitions int `json:"sweep_repetitions"` - PRSmokeDrain string `json:"pr_smoke_drain,omitempty"` - started time.Time + Version int `json:"schema_version"` + Lane string `json:"lane"` + HostClass string `json:"host_class"` + Machine string `json:"machine"` + Load string `json:"load_binary"` + Manifest string `json:"manifest"` + LargeFileManifest string `json:"large_file_manifest,omitempty"` + MainArtifact string `json:"main_artifact,omitempty"` + Builds map[string]laneBuild `json:"builds"` + Repeats int `json:"repeats"` + Duration string `json:"duration"` + Deadline string `json:"deadline"` + WarmTimeout string `json:"warm_timeout"` + StopTimeout string `json:"stop_timeout"` + SweepDuration string `json:"sweep_duration"` + SweepRepetitions int `json:"sweep_repetitions"` + PRSmokeDrain string `json:"pr_smoke_drain,omitempty"` + started time.Time } type laneTask struct { @@ -162,6 +163,9 @@ func readLaneConfig(path string, editGate ...bool) (laneConfig, error) { return filepath.Join(base, value) } c.Load, c.Manifest = resolve(c.Load), resolve(c.Manifest) + if c.LargeFileManifest != "" { + c.LargeFileManifest = resolve(c.LargeFileManifest) + } if c.MainArtifact != "" { c.MainArtifact = resolve(c.MainArtifact) } @@ -401,7 +405,11 @@ func writeNewJSON(path string, value any) error { } func taskManifest(c laneConfig, task laneTask, root string) (string, error) { - data, err := os.ReadFile(c.Manifest) + manifest := c.Manifest + if task.EditGate && task.Scenario == "loadedit-4-largefile" { + manifest = c.LargeFileManifest + } + data, err := os.ReadFile(manifest) if err != nil { return "", err } @@ -410,6 +418,9 @@ func taskManifest(c laneConfig, task laneTask, root string) (string, error) { return "", err } spec := scenarioSpecs[task.Scenario] + if task.EditGate && task.Scenario == "loadedit-4-largefile" { + spec.editors, spec.repos = 4, 1 + } if len(corpora) < spec.repos { return "", fmt.Errorf("%s needs %d pinned corpora", task.Scenario, spec.repos) } @@ -441,6 +452,9 @@ func taskArgs(c laneConfig, task laneTask, manifest, scratch, report string) []s spec := scenarioSpecs[task.Scenario] if task.EditGate { spec.wire, spec.agents = "edit-and-probe", 0 + if task.Scenario == "loadedit-4-largefile" { + spec.editors, spec.repos = 4, 1 + } } host := "native" if c.HostClass == "reference_linux" { diff --git a/bench/load/edit_gate_runtime_test.go b/bench/load/edit_gate_runtime_test.go index 654090524..5840088bb 100644 --- a/bench/load/edit_gate_runtime_test.go +++ b/bench/load/edit_gate_runtime_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -382,3 +384,47 @@ func TestEditGateCompactCoverageRequiresPostACKSemanticAndRefusalProof(t *testin }) } } + +func TestEditGateRetainsActualAdmittedTargetLineCount(t *testing.T) { + for _, lines := range []int{9999, 10000, 10001} { + t.Run(fmt.Sprint(lines), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = true + plan.Steps[0].ProbeMode = "json" + dir := t.TempDir() + source := "package fixture\r\n" + strings.Repeat("// padding\r\n", lines-2) + c.EditAnchor + file := filepath.Join(dir, c.File) + if err := os.WriteFile(file, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(stubFrame) (any, *rpcError) { return stubContent(map[string]any{}), nil }) + defer run.states[0].finishRaw() + // Later bytes cannot promote an initially small target into the + // separate large-file admission class. + if err := os.WriteFile(file, []byte(source+"\n"+strings.Repeat("// later\n", 10000)), 0o600); err != nil { + t.Fatal(err) + } + _, _, finish, _ := run.beginEdit(ctx, plan.Steps[0]) + finish() + data, err := json.Marshal(run.states[0].record) + if err != nil { + t.Fatal(err) + } + var wire struct { + Lines *int `json:"admitted_target_lines"` + } + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire.Lines == nil || *wire.Lines != lines { + t.Errorf("target class was omitted or borrowed from later bytes: %s", data) + } + }) + } +} diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index aa33c6369..3373756b9 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -29,6 +30,7 @@ type editProbeReadback struct { ObservationOnly bool `json:"observation_only,omitempty"` } type editProbeRecord struct { + AdmittedTargetLines *int `json:"admitted_target_lines,omitempty"` MutationTiers *editProbeLiveTiers `json:"mutation_tiers,omitempty"` GateReadback *Metric `json:"t_gate_readback_ms,omitempty"` ACKReadbackExact *bool `json:"ack_readback_exact,omitempty"` @@ -738,6 +740,22 @@ func (r *editProbeRuntime) beginEdit(ctx context.Context, step editProbeStep) (* s.record.StartedNS = &ns s.record.StartLatenessNS = &late s.record.Outcome = "in_flight" + if r.plan.GateModes { + initial := r.original + if len(r.p.Repos) > 1 { + initial = nil + if repo, err := r.repoFor(step); err == nil { + initial = r.originals[repo.Path] + } + } + if initial != nil { + lines := bytes.Count(initial, []byte{'\n'}) + if len(initial) > 0 && initial[len(initial)-1] != '\n' { + lines++ + } + s.record.AdmittedTargetLines = &lines + } + } if s.record.ActivatedNS == nil { s.record.Errors = append(s.record.Errors, "missing pre-edit activation") } diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index 9ecf1d4bb..b17ba87bd 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -645,6 +645,16 @@ } ] }, + "admitted_target_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "batch": { "anyOf": [ { diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index a3b38cd64..5feaa97ea 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -632,6 +632,16 @@ } ] }, + "admitted_target_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "batch": { "anyOf": [ { From 7140ffe6d5fc5624cd0d8ce39be6c67065f091be Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 20:46:43 +0100 Subject: [PATCH 080/138] test: preserve Windows path and coarse-clock measurement evidence --- .../store_sqlite/sqlite_sleep_gauge_test.go | 13 ++- internal/mcp/search_golden_test.go | 98 ++++++++++++++++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go b/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go index a0a3a74e9..65c7b4154 100644 --- a/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go +++ b/internal/graph/store_sqlite/sqlite_sleep_gauge_test.go @@ -51,7 +51,10 @@ func TestSQLiteSleepGaugeCountsBusyHandlerSleeps(t *testing.T) { require.Positive(t, split.BusySleeps) require.GreaterOrEqual(t, split.BusySleep, 150*time.Millisecond, "the busy handler's sleep was not counted") require.Positive(t, split.ReadTxns) - require.Positive(t, split.ReadTxn) + // Windows can finish every short transaction in the same wall-clock + // cell. Completed transaction count above proves real read work; only + // the deliberately blocked busy-handler interval has a positive floor. + require.GreaterOrEqual(t, split.ReadTxn, time.Duration(0)) } // The wrapper classifies whole-millisecond delays as the busy handler's and @@ -67,3 +70,11 @@ func TestSQLiteSleepGaugeClassifiesDelays(t *testing.T) { require.Equal(t, int64(2), after.WALRetryCalls-before.WALRetryCalls) require.GreaterOrEqual(t, after.BusyNanos-before.BusyNanos, int64(time.Millisecond)) } + +func TestReaderWaitSplitKeepsCompletedReadsAtZeroClockPrecision(t *testing.T) { + before := ReaderWaitMark{ReadTxns: 7, ReadTxnNanos: 1000} + after := ReaderWaitMark{ReadTxns: 10, ReadTxnNanos: 1000} + split := after.Split(before) + require.EqualValues(t, 3, split.ReadTxns) + require.Zero(t, split.ReadTxn, "equal clock cells remain measured zero, not fabricated positive time") +} diff --git a/internal/mcp/search_golden_test.go b/internal/mcp/search_golden_test.go index 7e35010fa..52f515660 100644 --- a/internal/mcp/search_golden_test.go +++ b/internal/mcp/search_golden_test.go @@ -1,6 +1,7 @@ package mcp import ( + "encoding/json" "flag" "os" "path/filepath" @@ -24,6 +25,8 @@ var updateSearchGolden = flag.Bool("update-search-golden", false, "rewrite the s // random on every call. var searchGoldenCursor = regexp.MustCompile(`"next_cursor":"[A-Za-z0-9_-]+"`) +var searchGoldenAbsolutePath = regexp.MustCompile(`("absolute_file_path"\s*:\s*)("(?:\\.|[^"\\])*")`) + // searchGoldenFixture is a small module whose names share prefixes, so exact, // prefix and concept queries rank different candidate sets, and whose bodies // repeat literals across files, so text queries hit several paths. @@ -147,9 +150,7 @@ func TestSearchOutputsMatchGoldens(t *testing.T) { res := callTool(t, srv, tc.tool, tc.args) require.NotEmpty(t, res.Content) got := res.Content[0].(mcplib.TextContent).Text - for _, root := range roots { - got = strings.ReplaceAll(got, root, "$ROOT") - } + got = normalizeSearchGoldenRoots(got, roots) got = searchGoldenCursor.ReplaceAllLiteralString(got, `"next_cursor":"$CURSOR"`) golden := filepath.Join("testdata", "search_golden", tc.name+".txt") if *updateSearchGolden { @@ -163,3 +164,94 @@ func TestSearchOutputsMatchGoldens(t *testing.T) { }) } } + +func normalizeSearchGoldenRoots(got string, roots []string) string { + // Rewrite only the path string token, retaining field order, whitespace, + // exact numeric spelling and every unrelated source string byte. + if json.Valid([]byte(got)) { + return searchGoldenAbsolutePath.ReplaceAllStringFunc(got, func(match string) string { + parts := searchGoldenAbsolutePath.FindStringSubmatch(match) + var path string + if json.Unmarshal([]byte(parts[2]), &path) != nil { + return match + } + normalized := normalizeSearchGoldenPath(path, roots) + if normalized == path { + return match + } + encoded, err := json.Marshal(normalized) + if err != nil { + return match + } + return parts[1] + string(encoded) + }) + } + if strings.HasPrefix(got, "GCX1 ") { + lines := strings.Split(got, "\n") + column := -1 + for _, word := range strings.Fields(lines[0]) { + if fields, ok := strings.CutPrefix(word, "fields="); ok { + for i, name := range strings.Split(fields, ",") { + if name == "path_abs" { + column = i + } + } + } + } + if column >= 0 { + for i, line := range lines[1:] { + if strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, "\t") + if column < len(fields) { + fields[column] = normalizeSearchGoldenPath(fields[column], roots) + lines[i+1] = strings.Join(fields, "\t") + } + } + } + return strings.Join(lines, "\n") + } + for _, root := range roots { + got = strings.ReplaceAll(got, root, "$ROOT") + } + return got +} + +func normalizeSearchGoldenPath(path string, roots []string) string { + original := path + path = strings.ReplaceAll(path, `\`, "/") + for _, root := range roots { + root = strings.TrimSuffix(strings.ReplaceAll(root, `\`, "/"), "/") + if path == root { + return "$ROOT" + } + if strings.HasPrefix(path, root+"/") { + return "$ROOT" + strings.TrimPrefix(path, root) + } + } + return original +} + +func TestSearchGoldenRootNormalizationPreservesActualWindowsPaths(t *testing.T) { + root := `D:\a\_temp\owned` + for _, mode := range []string{"json", "gcx"} { + t.Run(mode, func(t *testing.T) { + var actual, want string + if mode == "json" { + value := map[string]any{"absolute_file_path": root + `\server\server.go`, "doc": "literal " + root, "unrelated": root + `-sibling\server.go`} + data, err := json.Marshal(value) + require.NoError(t, err) + actual = string(data) + value["absolute_file_path"] = "$ROOT/server/server.go" + data, err = json.Marshal(value) + require.NoError(t, err) + want = string(data) + } else { + actual = "GCX1 tool=search_symbols fields=id,path_abs,sig\nserver::Handle\t" + root + `\server\server.go` + "\t" + `func literal("D:\code")` + "\n" + want = "GCX1 tool=search_symbols fields=id,path_abs,sig\nserver::Handle\t$ROOT/server/server.go\t" + `func literal("D:\code")` + "\n" + } + require.Equal(t, want, normalizeSearchGoldenRoots(actual, []string{root})) + }) + } +} From afa027816af20b1937ce724adfafdb01276627e7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:15:00 +0100 Subject: [PATCH 081/138] indexer: retain mutation resolve mutex waits as diagnostic subsets --- internal/indexer/canonical_attempt_window.go | 2 +- internal/indexer/canonical_patch_laps.go | 12 +- internal/indexer/edit_phase_timings.go | 15 +- internal/indexer/incremental_watcher_batch.go | 10 +- internal/indexer/multi.go | 6 +- .../indexer/mutation_resolve_mutex_wait.go | 108 ++++++++ .../mutation_resolve_mutex_wait_test.go | 247 ++++++++++++++++++ internal/indexer/workspace_resolve.go | 7 +- internal/resolver/cross_repo_incremental.go | 15 ++ internal/resolver/resolve_mutex_observer.go | 24 ++ .../resolver/resolve_mutex_observer_test.go | 124 +++++++++ internal/resolver/resolver.go | 14 +- 12 files changed, 564 insertions(+), 20 deletions(-) create mode 100644 internal/indexer/mutation_resolve_mutex_wait.go create mode 100644 internal/indexer/mutation_resolve_mutex_wait_test.go create mode 100644 internal/resolver/resolve_mutex_observer.go create mode 100644 internal/resolver/resolve_mutex_observer_test.go diff --git a/internal/indexer/canonical_attempt_window.go b/internal/indexer/canonical_attempt_window.go index 4cfb83d65..0a5568e42 100644 --- a/internal/indexer/canonical_attempt_window.go +++ b/internal/indexer/canonical_attempt_window.go @@ -100,7 +100,7 @@ func (p *PublicationPhaseRecord) completePhysicalAttempt(stamps *PublicationStam } } if window.accounting.observed { - if p.patchLaps == nil || *p.patchLaps != lateDeep || p.admissions == nil || *p.admissions != lateAdmissions || + if p.patchLaps == nil || !p.patchLaps.sameAccounting(lateDeep) || p.admissions == nil || *p.admissions != lateAdmissions || (p.laneWait == nil && (lateLane.started || lateLane.invalid)) || (p.laneWait != nil && *p.laneWait != lateLane) || !window.accounting.agreesWithAdmissions(lateAdmissions, lateLane) { window.accounting.invalid = true diff --git a/internal/indexer/canonical_patch_laps.go b/internal/indexer/canonical_patch_laps.go index 650e616e9..ff1a3e2c7 100644 --- a/internal/indexer/canonical_patch_laps.go +++ b/internal/indexer/canonical_patch_laps.go @@ -43,6 +43,7 @@ type canonicalLapTotal struct { } type canonicalPatchLaps struct { + resolveMutexWait mutationResolveMutexWait laps [canonicalPatchLapCount]canonicalLapTotal lastEnd time.Time active int @@ -126,6 +127,9 @@ func (p *canonicalPatchLaps) beginLapLocked(lap canonicalPatchLap, at time.Time) } func (p *canonicalPatchLaps) endLapLocked(lap canonicalPatchLap, start, at time.Time) { + if lap == patchResolve { + p.resolveMutexWait.finishResolveSpan(start, at) + } v := &p.laps[lap] if at.IsZero() || at.Before(start) || p.active != 1 || p.activeKind != lap || !p.activeStarted.Equal(start) { p.invalid = true @@ -183,15 +187,16 @@ type canonicalLapEvidence struct { } type canonicalPatchEvidence struct { - laps [canonicalDeepLapCount]canonicalLapEvidence - invalid bool + resolveMutexWait mutationResolveMutexEvidence + laps [canonicalDeepLapCount]canonicalLapEvidence + invalid bool } // freeze converts only same-process monotonic readings, never wall strings. // Called under the collector lock; the returned value has no mutable aliases. func (p *canonicalPatchLaps) freeze(origin time.Time) canonicalPatchEvidence { openOuterControl := p.controlObserved && p.active == 1 && isAttemptControl(p.activeKind) - out := canonicalPatchEvidence{invalid: p.invalid || p.active != 0 && !openOuterControl} + out := canonicalPatchEvidence{invalid: p.invalid || p.active != 0 && !openOuterControl, resolveMutexWait: p.resolveMutexWait.freeze(origin)} for i, v := range p.laps[:canonicalDeepLapCount] { out.laps[i] = canonicalLapEvidence{totalNS: int64(v.total), begun: v.begun, done: v.done, incomplete: v.incomplete} if v.begun != 0 { @@ -254,6 +259,7 @@ func (p *canonicalPatchEvidence) project(offsets map[PublicationPhase]int64, ter ms := float64(v.totalNS) / 1e6 out.Milliseconds[name], out.States[name] = &ms, "measured" } + out.Window.ResolveMutexWait = p.resolveMutexWait.project(p.laps[patchResolve], invalid || !hasStart || !hasEnd) } func (s *PublicationStamps) beginDerivedPatchLap() canonicalLapSpan { diff --git a/internal/indexer/edit_phase_timings.go b/internal/indexer/edit_phase_timings.go index df3d15387..37f3796ca 100644 --- a/internal/indexer/edit_phase_timings.go +++ b/internal/indexer/edit_phase_timings.go @@ -17,13 +17,14 @@ type EditPhaseTimings struct { // identify the acknowledgement's default reindex wait. It is not a phase // measurement or a claim of complete phase accounting. type EditTimingWindow struct { - Clock string `json:"clock"` - Coverage string `json:"coverage"` - AcknowledgementWallMS *float64 `json:"ack_wall_ms"` - PatchWallMS *float64 `json:"patch_wall_ms"` - PatchAccounting *EditPatchAccounting `json:"patch_accounting,omitempty"` - PublicationWallMS *float64 `json:"publication_wall_ms"` - PublicationUnattributedWallMS *float64 `json:"publication_unattributed_ms"` + ResolveMutexWait *EditResolveMutexWait `json:"resolve_mutex_wait,omitempty"` + Clock string `json:"clock"` + Coverage string `json:"coverage"` + AcknowledgementWallMS *float64 `json:"ack_wall_ms"` + PatchWallMS *float64 `json:"patch_wall_ms"` + PatchAccounting *EditPatchAccounting `json:"patch_accounting,omitempty"` + PublicationWallMS *float64 `json:"publication_wall_ms"` + PublicationUnattributedWallMS *float64 `json:"publication_unattributed_ms"` } var editAckPhaseNames = [...]string{ diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index d1f2b8b69..5bd7859c8 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -336,7 +336,7 @@ func (idx *Indexer) incrementalEvictWatcherPath(path string) (nodesRemoved, edge idx.incrementalResolveFilesHook(frontier) return } - idx.resolver.ResolveFilesAndIncoming(frontier) + idx.resolver.ResolveFilesAndIncomingObserved(frontier, eviction.batch.publicationStamps().mutationResolveMutexObserver()) }) } else if needed && !exact { idx.observeIncrementalCatchup("resolve", nil) @@ -395,7 +395,7 @@ func (idx *Indexer) incrementalWatcherPaths(root string, paths []string, mode in idx.incrementalResolveFilesHook(frontier) return } - idx.resolver.ResolveFilesAndIncoming(frontier) + idx.resolver.ResolveFilesAndIncomingObserved(frontier, batch.publicationStamps().mutationResolveMutexObserver()) }) names := mode.publication.beginPatchLap(patchResolve) idx.resolveReceiptNamePendings(receipt) @@ -572,7 +572,7 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( idx.incrementalResolveFilesHook(frontier) return } - mi.runMasterResolveFilesWithEvidence(frontier, false, idx.resolver) + mi.runMasterResolveFilesObserved(frontier, false, idx.resolver, batch.publicationStamps().mutationResolveMutexObserver()) }) crossRepoFiles = appendUniqueSorted(crossRepoFiles, resolvedFiles...) if receipt != nil && receipt.Complete { @@ -584,7 +584,7 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( } } else if needed && len(files) > 0 { resolve := batch.publicationStamps().beginPatchLap(patchResolve) - mi.runMasterResolveFiles(files, false) + mi.runMasterResolveFilesObserved(files, false, nil, batch.publicationStamps().mutationResolveMutexObserver()) resolve.end() if receipt != nil && receipt.Complete { names := batch.publicationStamps().beginPatchLap(patchResolve) @@ -629,7 +629,7 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( } cross := batch.publicationStamps().beginPatchLap(patchResolve) if len(crossRepoFiles) > 0 { - mi.runCrossRepoResolveFiles(crossRepoFiles) + mi.runCrossRepoResolveMutationFrontiersObserved(crossRepoFiles, crossRepoFiles, crossRepoFiles, batch.publicationStamps().mutationResolveMutexObserver()) } else { wide = true mi.runCrossRepoResolve(false) diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 6b4dad873..1bf20a642 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -1084,13 +1084,17 @@ func (mi *MultiIndexer) runMasterResolveFiles(files []string, useLSP bool) { // holding the mutation's pre-eviction evidence (resolveWithDeferredEvidence), // which the master resolver inherits for this one resolve. func (mi *MultiIndexer) runMasterResolveFilesWithEvidence(files []string, useLSP bool, evidence *resolver.Resolver) { + mi.runMasterResolveFilesObserved(files, useLSP, evidence, nil) +} + +func (mi *MultiIndexer) runMasterResolveFilesObserved(files []string, useLSP bool, evidence *resolver.Resolver, observe func(time.Time, time.Time)) { master := mi.newMasterResolver(useLSP) if master == nil { return } master.InheritIncrementalEvidence(evidence) mt := time.Now() - stats := master.ResolveFilesAndIncoming(files) + stats := master.ResolveFilesAndIncomingObserved(files, observe) mi.logger.Info("DEFERRED-TIMING master.ResolveFilesAndIncoming", zap.Duration("elapsed", time.Since(mt)), zap.Bool("lsp_enabled", useLSP && mi.resolverLSPHelper != nil), diff --git a/internal/indexer/mutation_resolve_mutex_wait.go b/internal/indexer/mutation_resolve_mutex_wait.go new file mode 100644 index 000000000..9697fded0 --- /dev/null +++ b/internal/indexer/mutation_resolve_mutex_wait.go @@ -0,0 +1,108 @@ +package indexer + +import "time" + +// EditResolveMutexWait is a diagnostic subset of resolve, not another disjoint +// patch lap. Milliseconds sums the observed acquisitions of this execution. +type EditResolveMutexWait struct { + Milliseconds *float64 `json:"milliseconds"` + State string `json:"state"` + SubsetOf string `json:"subset_of"` + Acquisitions uint64 `json:"acquisitions"` +} + +type mutationResolveMutexWait struct { + total time.Duration + acquisitions uint64 + first, last time.Time + spanStarted, spanLast time.Time + invalid bool +} + +type mutationResolveMutexEvidence struct { + totalNS, firstNS, lastNS int64 + acquisitions uint64 + invalid, incomplete bool +} + +func (s *PublicationStamps) mutationResolveMutexObserver() func(time.Time, time.Time) { + if s == nil || s.patchLaps == nil { + return nil + } + return s.observeMutationResolveMutexWait +} + +func (s *PublicationStamps) observeMutationResolveMutexWait(started, acquired time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + p := s.patchLaps + d := &p.resolveMutexWait + if d.acquisitions == ^uint64(0) { + d.invalid = true + return + } + d.acquisitions++ + if p.active != 1 || p.activeKind != patchResolve || started.IsZero() || acquired.IsZero() || acquired.Before(started) || started.Before(p.activeStarted) { + d.invalid = true + return + } + elapsed := acquired.Sub(started) + if elapsed < 0 || d.total > time.Duration(1<<63-1)-elapsed { + d.invalid = true + return + } + d.total += elapsed + if d.first.IsZero() || started.Before(d.first) { + d.first = started + } + if acquired.After(d.last) { + d.last = acquired + } + if d.spanStarted.IsZero() { + d.spanStarted = p.activeStarted + } + if d.spanStarted != p.activeStarted { + d.invalid = true + } + if acquired.After(d.spanLast) { + d.spanLast = acquired + } +} + +// Validate each span separately; a later resolve span must not make an +// acquisition in an intervening bookkeeping gap look enclosed. +func (d *mutationResolveMutexWait) finishResolveSpan(started, ended time.Time) { + if !d.spanStarted.IsZero() && (d.spanStarted != started || d.spanLast.After(ended)) { + d.invalid = true + } + d.spanStarted, d.spanLast = time.Time{}, time.Time{} +} + +func (d mutationResolveMutexWait) freeze(origin time.Time) mutationResolveMutexEvidence { + out := mutationResolveMutexEvidence{totalNS: int64(d.total), acquisitions: d.acquisitions, invalid: d.invalid, incomplete: !d.spanStarted.IsZero()} + if !d.first.IsZero() { + out.firstNS = d.first.Sub(origin).Nanoseconds() + out.lastNS = d.last.Sub(origin).Nanoseconds() + } + return out +} + +func (d mutationResolveMutexEvidence) project(resolve canonicalLapEvidence, enclosingInvalid bool) *EditResolveMutexWait { + if d.acquisitions == 0 && !d.invalid { + return nil + } + out := &EditResolveMutexWait{State: "not_observed", SubsetOf: "resolve", Acquisitions: d.acquisitions} + if d.invalid || enclosingInvalid || d.firstNS < resolve.firstNS || d.lastNS > resolve.lastNS || d.totalNS < 0 { + out.State = "invalid_order" + } else if !d.incomplete && resolve.begun > 0 && resolve.begun == resolve.done && !resolve.incomplete { + ms := float64(d.totalNS) / 1e6 + out.Milliseconds, out.State = &ms, "measured" + } + return out +} + +// Keep the original P evidence equality: diagnostic subsets never establish +// or invalidate the disjoint accounting witness. +func (p canonicalPatchEvidence) sameAccounting(other canonicalPatchEvidence) bool { + return p.laps == other.laps && p.invalid == other.invalid +} diff --git a/internal/indexer/mutation_resolve_mutex_wait_test.go b/internal/indexer/mutation_resolve_mutex_wait_test.go new file mode 100644 index 000000000..2bf13b09d --- /dev/null +++ b/internal/indexer/mutation_resolve_mutex_wait_test.go @@ -0,0 +1,247 @@ +package indexer + +import ( + "encoding/json" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestMutationResolveMutexSubsetAggregatesWithoutChangingPatchLaps(t *testing.T) { + r, s, origin := scalarPatchFixture(t) + at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } + span := s.beginPatchLapAt(patchResolve, at(3)) + before := s.patchLaps.laps + observe := s.mutationResolveMutexObserver() + observe(at(4), at(6)) + observe(at(7), at(8)) + if before != s.patchLaps.laps { + t.Fatal("diagnostic was added to disjoint patch laps") + } + span.endAt(at(12)) + span = s.beginPatchLapAt(patchResolve, at(13)) + observe(at(14), at(14)) // A real zero acquisition remains measured. + span.endAt(at(18)) + s.markAt(PublicationPatchExecutionFinished, at(20)) + r.Absorb(s) + r.MarkAt(PublicationTicketCompleted, at(21)) + snapshot := r.Snapshot() + out := snapshot.EditTimings() + d := out.Window.ResolveMutexWait + if d == nil || d.State != "measured" || d.SubsetOf != "resolve" || d.Acquisitions != 3 || d.Milliseconds == nil || *d.Milliseconds != 3 { + t.Fatalf("mutation diagnostic lost: %+v", d) + } + if out.Milliseconds["resolve"] == nil || *out.Milliseconds["resolve"] != 14 { + t.Fatal("diagnostic changed enclosing resolve duration") + } + if _, present := out.Milliseconds["resolve_mutex_wait"]; present { + t.Fatal("diagnostic entered phases_ms") + } + encoded, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err = json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + diagnostic := wire["phase_timing"].(map[string]any)["resolve_mutex_wait"].(map[string]any) + if diagnostic["subset_of"] != "resolve" || diagnostic["acquisitions"] != float64(3) { + t.Fatal("wrong wire contract") + } + s.observeMutationResolveMutexWait(at(30), at(31)) + if r.Snapshot().EditTimings().Window.ResolveMutexWait.Acquisitions != 3 { + t.Fatal("later collector mutated published diagnostic") + } +} + +func TestMutationResolveMutexSubsetRejectsWrongSpanWithoutPoisoningAccounting(t *testing.T) { + for _, kind := range []string{"before", "after", "backwards", "wrong_lap"} { + t.Run(kind, func(t *testing.T) { + r, s, origin := scalarPatchFixture(t) + at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } + lap := patchResolve + if kind == "wrong_lap" { + lap = patchParse + } + span := s.beginPatchLapAt(lap, at(3)) + a, b := 4, 5 + switch kind { + case "before": + a = 2 + case "after": + b = 10 + case "backwards": + b = 3 + } + s.mutationResolveMutexObserver()(at(a), at(b)) + span.endAt(at(7)) + // A later span cannot legitimize the earlier acquisition's out-of-span end. + scalarSpan(s, patchResolve, origin, 11*time.Millisecond, 15*time.Millisecond) + s.markAt(PublicationPatchExecutionFinished, at(16)) + r.Absorb(s) + r.MarkAt(PublicationTicketCompleted, at(17)) + out := r.Snapshot().EditTimings() + d := out.Window.ResolveMutexWait + if d == nil || d.State != "invalid_order" || d.Milliseconds != nil || d.Acquisitions != 1 { + t.Fatalf("invalid diagnostic accepted: %+v", d) + } + if out.States["resolve"] != "measured" { + t.Fatal("diagnostic poisoned disjoint accounting") + } + }) + } +} + +func TestMutationResolveMutexSubsetNilAndZeroCompatibility(t *testing.T) { + var absent *PublicationStamps + if absent.mutationResolveMutexObserver() != nil || (&PublicationStamps{}).mutationResolveMutexObserver() != nil { + t.Fatal("unobserved execution allocated callback") + } + r, s, origin := scalarPatchFixture(t) + at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } + span := s.beginPatchLapAt(patchResolve, at(3)) + span.endAt(at(7)) + s.markAt(PublicationPatchExecutionFinished, at(8)) + r.Absorb(s) + r.MarkAt(PublicationTicketCompleted, at(9)) + if r.Snapshot().EditTimings().Window.ResolveMutexWait != nil { + t.Fatal("missing observer became measured zero") + } + r, s, origin = scalarPatchFixture(t) + span = s.beginPatchLapAt(patchResolve, at(3)) + s.mutationResolveMutexObserver()(at(4), at(4)) + span.endAt(at(7)) + s.markAt(PublicationPatchExecutionFinished, at(8)) + r.Absorb(s) + r.MarkAt(PublicationTicketCompleted, at(9)) + d := r.Snapshot().EditTimings().Window.ResolveMutexWait + if d == nil || d.State != "measured" || d.Milliseconds == nil || *d.Milliseconds != 0 || d.Acquisitions != 1 { + t.Fatalf("actualzero lost: %+v", d) + } +} + +func TestMutationResolveMutexSubsetStaysWithPhysicalCoalescedWinner(t *testing.T) { + origin := time.Now() + at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } + recorder := NewPublicationPhaseRecorder(8, 1) + defer runtime.KeepAlive(recorder) + first := recorder.Begin("", "first", PublicationSourceMCPEdit, origin) + second := recorder.Begin("", "second", PublicationSourceMCPEdit, origin) + if !first.bindCanonicalTicket(1, origin) || !second.bindCanonicalTicket(2, origin) { + t.Fatal("ticket binding failed") + } + early := first.Snapshot() + winner := first.beginCoalescedAttempt(3, 0, at(1)) + if winner == nil { + t.Fatal("no physical winner") + } + stamps := winner.newCanonicalPatchStamps() + stamps.markAt(PublicationPatchExecutionStarted, at(2)) + span := stamps.beginPatchLapAt(patchResolve, at(3)) + stamps.mutationResolveMutexObserver()(at(4), at(6)) + span.endAt(at(7)) + stamps.markAt(PublicationPatchExecutionFinished, at(8)) + winner.Absorb(stamps) + winner.MarkAt(PublicationTicketCompleted, at(9)) + first.linkCoalescedWinner(winner, 1, 3) + second.linkCoalescedWinner(winner, 2, 3) + a, b := first.CoalescedPublication(), second.CoalescedPublication() + if a == nil || b == nil || a.Publication.Key != b.Publication.Key || a.AppliedGeneration != 3 || b.AppliedGeneration != 3 { + t.Fatal("physical owner linkage changed") + } + if early.EditTimings().Window.ResolveMutexWait != nil || first.Snapshot().EditTimings().Window.ResolveMutexWait != nil || second.Snapshot().EditTimings().Window.ResolveMutexWait != nil { + t.Fatal("coalesced receipt borrowed physical mutex evidence") + } + d := a.Timings.Window.ResolveMutexWait + if d == nil || d.State != "measured" || d.Acquisitions != 1 || d.Milliseconds == nil || *d.Milliseconds != 2 { + t.Fatalf("winner lost diagnostic: %+v", d) + } + if b.Timings.Window.ResolveMutexWait.Acquisitions != d.Acquisitions { + t.Fatal("shared execution invented another acquisition") + } + stamps.observeMutationResolveMutexWait(at(10), at(11)) + if a.Timings.Window.ResolveMutexWait.State != "measured" || winner.Snapshot().EditTimings().Window.ResolveMutexWait.Acquisitions != 1 { + t.Fatal("later observer mutated physical snapshot") + } +} + +func TestMutationResolveMutexSubsetActualPointExecutors(t *testing.T) { + for _, multi := range []bool{false, true} { + t.Run(map[bool]string{false: "single", true: "multi"}[multi], func(t *testing.T) { + record := newPointPublication(t, "actual-mutex") + if !record.bindCanonicalTicket(1, time.Now()) { + t.Fatal("ticket binding failed") + } + stamps := record.newCanonicalPatchStamps() + var execute func() error + if multi { + mi, _, roots := repoTopologyFixture(t) + path := filepath.Join(roots["app"], "main.go") + writeFile(t, path, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Added() { Run() }\n") + execute = func() error { _, err := mi.incrementalPointRepoRawObserved("app", path, stamps); return err } + } else { + root, idx, w := inertTestWatcher(t, "edit.go", "package p\nfunc Callee() {}\n") + w.degradedNoFsnotify = true + t.Cleanup(func() { _ = w.Stop() }) + path := filepath.Join(root, "edit.go") + bumpMtime(t, path, "package p\nfunc Callee() {}\nfunc Added() { Callee() }\n") + execute = func() error { _, err := idx.incrementalPointWatcherPathObserved(root, path, stamps); return err } + } + stamps.markAt(PublicationPatchExecutionStarted, time.Now()) + if err := execute(); err != nil { + t.Fatal(err) + } + stamps.markAt(PublicationPatchExecutionFinished, time.Now()) + record.Absorb(stamps) + record.Mark(PublicationTicketCompleted) + out := record.Snapshot().EditTimings() + d := out.Window.ResolveMutexWait + minimum := uint64(1) + if multi { + minimum = 2 + } + if d == nil || d.State != "measured" || d.Milliseconds == nil || d.Acquisitions < minimum { + t.Fatalf("actual executor did not retain scoped acquisitions: %+v resolve=%s", d, out.States["resolve"]) + } + if out.Milliseconds["resolve"] == nil || *d.Milliseconds > *out.Milliseconds["resolve"] { + t.Fatal("sequential mutex subset exceeded enclosing resolve wall") + } + }) + } +} + +func TestMutationResolveMutexDiagnosticCannotChangePhysicalAccounting(t *testing.T) { + for _, change := range []string{"diagnostic_only", "lap_changed", "invalid_changed"} { + t.Run(change, func(t *testing.T) { + stamps, origin, _ := completeControlledAttempt(t) + recorder := NewPublicationPhaseRecorder(2, 1) + defer runtime.KeepAlive(recorder) + record := recorder.Begin("", "accounting-owner", PublicationSourceMCPEdit, origin) + if !record.bindCanonicalTicket(1, origin) { + t.Fatal("binding failed") + } + record.Absorb(stamps) + record.MarkAt(PublicationTicketCompleted, origin.Add(32*time.Millisecond)) + switch change { + case "diagnostic_only": + stamps.observeMutationResolveMutexWait(origin.Add(20*time.Millisecond), origin.Add(21*time.Millisecond)) + case "lap_changed": + stamps.patchLaps.laps[patchResolve].total += time.Nanosecond + case "invalid_changed": + stamps.patchLaps.invalid = true + } + window, ok := record.completePhysicalAttempt(stamps, origin.Add(time.Millisecond), origin.Add(31*time.Millisecond), 0) + if !ok { + t.Fatal("physical owner was rejected") + } + if window.accounting.invalid != (change != "diagnostic_only") { + t.Fatalf("diagnostic isolation weakened canonical identity: %s invalid=%t", change, window.accounting.invalid) + } + if record.Snapshot().EditTimings().Window.ResolveMutexWait != nil { + t.Fatal("late diagnostic rewrote the frozen publication snapshot") + } + }) + } +} diff --git a/internal/indexer/workspace_resolve.go b/internal/indexer/workspace_resolve.go index e90b46440..9aeb82001 100644 --- a/internal/indexer/workspace_resolve.go +++ b/internal/indexer/workspace_resolve.go @@ -6,6 +6,7 @@ import ( "sort" "sync" "sync/atomic" + "time" "go.uber.org/zap" @@ -554,6 +555,10 @@ func (mi *MultiIndexer) runCrossRepoResolveMutationFiles(resolutionFiles, materi } func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiers(resolutionFiles, edgeSourceFiles, definitionFiles []string) { + mi.runCrossRepoResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles, nil) +} + +func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) { if len(resolutionFiles) == 0 && len(edgeSourceFiles) == 0 && len(definitionFiles) == 0 { return } @@ -561,7 +566,7 @@ func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiers(resolutionFiles, ed if cr == nil { return } - cr.ResolveMutationFrontiers(resolutionFiles, edgeSourceFiles, definitionFiles) + cr.ResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles, observe) mi.finishGoModuleCalls(cr) mi.reconcileRetargetedTestCalls(cr.TakeRetargetedTestCallFiles()) } diff --git a/internal/resolver/cross_repo_incremental.go b/internal/resolver/cross_repo_incremental.go index 8727395c7..96981097c 100644 --- a/internal/resolver/cross_repo_incremental.go +++ b/internal/resolver/cross_repo_incremental.go @@ -2,6 +2,7 @@ package resolver import ( "context" + "time" "go.uber.org/zap" @@ -58,14 +59,28 @@ func (cr *CrossRepoResolver) ResolveMutationFrontiers(resolutionFiles, edgeSourc // files' own outgoing edges, and its CrossRepoStats — which count exactly that // — would otherwise read as a complete pass. func (cr *CrossRepoResolver) ResolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles []string) (*CrossRepoStats, CrossRepoIncomingAdmission) { + return cr.resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles, nil) +} + +func (cr *CrossRepoResolver) resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) (*CrossRepoStats, CrossRepoIncomingAdmission) { stats := &CrossRepoStats{ByRepo: make(map[string]int)} var admission CrossRepoIncomingAdmission if cr == nil || cr.graph == nil || len(resolutionFiles) == 0 && len(edgeSourceFiles) == 0 && len(definitionFiles) == 0 { return stats, admission } + var started, acquired time.Time + if observe != nil { + started = time.Now() + } cr.mu.Lock() + if observe != nil { + acquired = time.Now() + } defer cr.mu.Unlock() + if observe != nil { + observe(started, acquired) + } if len(resolutionFiles) > 0 { frontier := collectIncrementalFileFrontier(cr.graph, resolutionFiles, nil) diff --git a/internal/resolver/resolve_mutex_observer.go b/internal/resolver/resolve_mutex_observer.go new file mode 100644 index 000000000..849cd7004 --- /dev/null +++ b/internal/resolver/resolve_mutex_observer.go @@ -0,0 +1,24 @@ +package resolver + +import "time" + +// ResolveFilesAndIncomingObserved reports this execution's actual mutex +// acquisition separately from module refresh and resolution. A nil observer +// retains the legacy path; an empty execution makes no observation. The +// callback runs under ResolveMutex and must be bounded and non-reentrant. +func (r *Resolver) ResolveFilesAndIncomingObserved(files []string, observe func(started, acquired time.Time)) *ResolveStats { + return r.resolveFilesAndIncoming(files, observe) +} + +// ResolveFilesAndIncoming runs the legacy unobserved execution. +func (r *Resolver) ResolveFilesAndIncoming(files []string) *ResolveStats { + return r.resolveFilesAndIncoming(files, nil) +} + +// ResolveMutationFrontiersObserved observes the scoped cross-repository +// execution's mutex acquisition. The callback has the same restrictions as +// ResolveFilesAndIncomingObserved; it is never installed on a shared resolver. +func (cr *CrossRepoResolver) ResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) *CrossRepoStats { + stats, _ := cr.resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles, observe) + return stats +} diff --git a/internal/resolver/resolve_mutex_observer_test.go b/internal/resolver/resolve_mutex_observer_test.go new file mode 100644 index 000000000..ce907c5f2 --- /dev/null +++ b/internal/resolver/resolve_mutex_observer_test.go @@ -0,0 +1,124 @@ +package resolver + +import ( + "reflect" + "sync" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestMutationResolveMutexObserverEndsBeforeModuleRefresh(t *testing.T) { + g := graph.New() + r := New(g) + enteringLock, refreshing, permitRefresh, done := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + var signal, unlock, allowRefresh sync.Once + core, _ := observer.New(zap.InfoLevel) + r.SetLogger(zap.New(zapcore.RegisterHooks(core, func(entry zapcore.Entry) error { + if entry.Message == "resolver: incremental phase starting" { + signal.Do(func() { close(enteringLock) }) + } + return nil + }))) + r.SetGoModuleLookup(func() map[string]string { close(refreshing); <-permitRefresh; return nil }) + observed := make(chan [2]time.Time, 1) + g.ResolveMutex().Lock() + release := func() { unlock.Do(g.ResolveMutex().Unlock) } + resume := func() { allowRefresh.Do(func() { close(permitRefresh) }) } + t.Cleanup(func() { + release() + resume() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("resolver did not drain") + } + }) + go func() { + defer close(done) + r.ResolveFilesAndIncomingObserved([]string{"missing.go"}, func(started, acquired time.Time) { observed <- [2]time.Time{started, acquired} }) + }() + select { + case <-enteringLock: + case <-time.After(2 * time.Second): + t.Fatal("resolve never reached mutex admission") + } + select { + case <-observed: + t.Fatal("held mutation mutex reported acquired before release") + default: + } + timer := time.NewTimer(20 * time.Millisecond) + <-timer.C + release() + select { + case <-refreshing: + case <-time.After(2 * time.Second): + t.Fatal("resolve did not reach module refresh") + } + select { + case interval := <-observed: + if interval[0].IsZero() || !interval[1].After(interval[0]) { + t.Fatalf("held mutex acquisition has no positive observed interval: %v", interval) + } + default: + t.Fatal("actual mutation acquisition was not observed before module refresh") + } + select { + case <-done: + t.Fatal("module refresh unexpectedly completed without its permit") + default: + } + resume() + <-done + select { + case <-observed: + t.Fatal("one execution reported multiple acquisition waits") + default: + } +} + +func TestMutationResolveMutexObserverLegacyNilAndEmptyCompatibility(t *testing.T) { + legacy := New(graph.New()).ResolveFilesAndIncoming([]string{"missing.go"}) + unobserved := New(graph.New()).ResolveFilesAndIncomingObserved([]string{"missing.go"}, nil) + if !reflect.DeepEqual(legacy, unobserved) { + t.Fatalf("nil observer changed resolution: legacy=%+v observed=%+v", legacy, unobserved) + } + calls := 0 + empty := New(graph.New()).ResolveFilesAndIncomingObserved(nil, func(time.Time, time.Time) { calls++ }) + if calls != 0 || !reflect.DeepEqual(empty, &ResolveStats{}) { + t.Fatal("empty execution fabricated an observed zero acquisition") + } +} + +func TestMutationResolveMutexObserverCrossRepoCompatibility(t *testing.T) { + files := []string{"missing.go"} + legacy := NewCrossRepo(graph.New()).ResolveMutationFrontiers(files, files, files) + unobserved := NewCrossRepo(graph.New()).ResolveMutationFrontiersObserved(files, files, files, nil) + if !reflect.DeepEqual(legacy, unobserved) { + t.Fatal("nil observer changed cross-repo resolution") + } + g := graph.New() + calls := 0 + NewCrossRepo(g).ResolveMutationFrontiersObserved(files, files, files, func(started, acquired time.Time) { + calls++ + if started.IsZero() || acquired.Before(started) { + t.Fatal("invalid cross-repo acquisition interval") + } + if g.ResolveMutex().TryLock() { + g.ResolveMutex().Unlock() + t.Fatal("cross-repo observer did not follow mutex acquisition") + } + }) + if calls != 1 { + t.Fatal("cross-repo execution did not report one actual acquisition") + } + NewCrossRepo(g).ResolveMutationFrontiersObserved(nil, nil, nil, func(time.Time, time.Time) { calls++ }) + if calls != 1 { + t.Fatal("empty cross-repo pass fabricated an acquisition") + } +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 7a34f7aba..29daf9e57 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3073,13 +3073,13 @@ func collectIncrementalFileFrontierKeys( return frontier } -// ResolveFilesAndIncoming runs the forward and reverse passes for a +// resolveFilesAndIncoming runs the forward and reverse passes for a // batch of files under one lock, one build of the per-pass indexes, and // one run of the attribution passes. The affected-by re-resolution path // uses this: calling ResolveFileAndIncoming per file would rebuild the // four pass indexes and re-run the whole-graph attribution sweeps once // per file, turning a bounded fan-out into N whole-graph passes. -func (r *Resolver) ResolveFilesAndIncoming(filePaths []string) *ResolveStats { +func (r *Resolver) resolveFilesAndIncoming(filePaths []string, observe func(started, acquired time.Time)) *ResolveStats { stats := &ResolveStats{} if len(filePaths) == 0 { return stats @@ -3123,8 +3123,18 @@ func (r *Resolver) ResolveFilesAndIncoming(filePaths []string) *ResolveStats { zap.Duration("total", time.Since(started))) }() finish := r.startIncrementalPhase(logger, "wait_lock") + var mutexStarted, mutexAcquired time.Time + if observe != nil { + mutexStarted = time.Now() + } r.mu.Lock() + if observe != nil { + mutexAcquired = time.Now() + } defer r.mu.Unlock() + if observe != nil { + observe(mutexStarted, mutexAcquired) + } r.refreshGoModules() lockDuration = finish() // The edited files may have (re)stamped global usings — reconcile the From 0deb8464173cb0eed5d491f3b74c3c9417f81a35 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:22:16 +0100 Subject: [PATCH 082/138] indexer: keep mutex diagnostics scoped to canonical physical executions --- .../indexer/mutation_resolve_mutex_wait.go | 4 +++- .../mutation_resolve_mutex_wait_test.go | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/indexer/mutation_resolve_mutex_wait.go b/internal/indexer/mutation_resolve_mutex_wait.go index 9697fded0..d01499e7b 100644 --- a/internal/indexer/mutation_resolve_mutex_wait.go +++ b/internal/indexer/mutation_resolve_mutex_wait.go @@ -25,8 +25,10 @@ type mutationResolveMutexEvidence struct { invalid, incomplete bool } +// Only the existing canonical physical P identity may own this diagnostic. +// The separate read-repair collector intentionally has no such ticket/origin. func (s *PublicationStamps) mutationResolveMutexObserver() func(time.Time, time.Time) { - if s == nil || s.patchLaps == nil { + if s == nil || s.patchLaps == nil || s.patchLaps.ticket == 0 || s.patchLaps.origin.IsZero() { return nil } return s.observeMutationResolveMutexWait diff --git a/internal/indexer/mutation_resolve_mutex_wait_test.go b/internal/indexer/mutation_resolve_mutex_wait_test.go index 2bf13b09d..a743da638 100644 --- a/internal/indexer/mutation_resolve_mutex_wait_test.go +++ b/internal/indexer/mutation_resolve_mutex_wait_test.go @@ -8,8 +8,17 @@ import ( "time" ) +func mutationMutexFixture(t *testing.T) (*PublicationPhaseRecord, *PublicationStamps, time.Time) { + t.Helper() + record, stamps, origin := scalarPatchFixture(t) + // Bind through the production collector factory, never a synthetic R owner. + stamps = record.newCanonicalPatchStamps() + stamps.markAt(PublicationPatchExecutionStarted, origin.Add(2*time.Millisecond)) + return record, stamps, origin +} + func TestMutationResolveMutexSubsetAggregatesWithoutChangingPatchLaps(t *testing.T) { - r, s, origin := scalarPatchFixture(t) + r, s, origin := mutationMutexFixture(t) at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } span := s.beginPatchLapAt(patchResolve, at(3)) before := s.patchLaps.laps @@ -59,7 +68,7 @@ func TestMutationResolveMutexSubsetAggregatesWithoutChangingPatchLaps(t *testing func TestMutationResolveMutexSubsetRejectsWrongSpanWithoutPoisoningAccounting(t *testing.T) { for _, kind := range []string{"before", "after", "backwards", "wrong_lap"} { t.Run(kind, func(t *testing.T) { - r, s, origin := scalarPatchFixture(t) + r, s, origin := mutationMutexFixture(t) at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } lap := patchResolve if kind == "wrong_lap" { @@ -96,10 +105,10 @@ func TestMutationResolveMutexSubsetRejectsWrongSpanWithoutPoisoningAccounting(t func TestMutationResolveMutexSubsetNilAndZeroCompatibility(t *testing.T) { var absent *PublicationStamps - if absent.mutationResolveMutexObserver() != nil || (&PublicationStamps{}).mutationResolveMutexObserver() != nil { + if absent.mutationResolveMutexObserver() != nil || (&PublicationStamps{}).mutationResolveMutexObserver() != nil || (&PublicationStamps{patchLaps: &canonicalPatchLaps{}}).mutationResolveMutexObserver() != nil { t.Fatal("unobserved execution allocated callback") } - r, s, origin := scalarPatchFixture(t) + r, s, origin := mutationMutexFixture(t) at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } span := s.beginPatchLapAt(patchResolve, at(3)) span.endAt(at(7)) @@ -109,7 +118,7 @@ func TestMutationResolveMutexSubsetNilAndZeroCompatibility(t *testing.T) { if r.Snapshot().EditTimings().Window.ResolveMutexWait != nil { t.Fatal("missing observer became measured zero") } - r, s, origin = scalarPatchFixture(t) + r, s, origin = mutationMutexFixture(t) span = s.beginPatchLapAt(patchResolve, at(3)) s.mutationResolveMutexObserver()(at(4), at(4)) span.endAt(at(7)) From ffe33895b198bcd2a6f4dd56b74fd97eb2a82f98 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:28:04 +0100 Subject: [PATCH 083/138] test: avoid discarding the mutation mutex fixture collector --- internal/indexer/mutation_resolve_mutex_wait_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/indexer/mutation_resolve_mutex_wait_test.go b/internal/indexer/mutation_resolve_mutex_wait_test.go index a743da638..517ba4d68 100644 --- a/internal/indexer/mutation_resolve_mutex_wait_test.go +++ b/internal/indexer/mutation_resolve_mutex_wait_test.go @@ -10,9 +10,9 @@ import ( func mutationMutexFixture(t *testing.T) (*PublicationPhaseRecord, *PublicationStamps, time.Time) { t.Helper() - record, stamps, origin := scalarPatchFixture(t) + record, _, origin := scalarPatchFixture(t) // Bind through the production collector factory, never a synthetic R owner. - stamps = record.newCanonicalPatchStamps() + stamps := record.newCanonicalPatchStamps() stamps.markAt(PublicationPatchExecutionStarted, origin.Add(2*time.Millisecond)) return record, stamps, origin } From 920a452b8117ec35e41f64d10ae39751f348b4bf Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:33:39 +0100 Subject: [PATCH 084/138] test(edit-gate): add build-isolated acknowledgement and reclaim regressions --- .../store_sqlite/edit_gate_seed_disabled.go | 6 ++ .../edit_gate_seed_disabled_test.go | 21 +++++++ .../store_sqlite/edit_gate_seed_enabled.go | 33 +++++++++++ .../edit_gate_seed_enabled_test.go | 23 ++++++++ internal/graph/store_sqlite/wal_reclaim.go | 4 +- internal/mcp/edit_gate_seed_disabled.go | 8 +++ internal/mcp/edit_gate_seed_disabled_test.go | 17 ++++++ internal/mcp/edit_gate_seed_enabled.go | 35 ++++++++++++ internal/mcp/edit_gate_seed_enabled_test.go | 55 +++++++++++++++++++ internal/mcp/edit_serialization.go | 6 ++ 10 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 internal/graph/store_sqlite/edit_gate_seed_disabled.go create mode 100644 internal/graph/store_sqlite/edit_gate_seed_disabled_test.go create mode 100644 internal/graph/store_sqlite/edit_gate_seed_enabled.go create mode 100644 internal/graph/store_sqlite/edit_gate_seed_enabled_test.go create mode 100644 internal/mcp/edit_gate_seed_disabled.go create mode 100644 internal/mcp/edit_gate_seed_disabled_test.go create mode 100644 internal/mcp/edit_gate_seed_enabled.go create mode 100644 internal/mcp/edit_gate_seed_enabled_test.go diff --git a/internal/graph/store_sqlite/edit_gate_seed_disabled.go b/internal/graph/store_sqlite/edit_gate_seed_disabled.go new file mode 100644 index 000000000..34e0d6b2a --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_disabled.go @@ -0,0 +1,6 @@ +//go:build !gortex_edit_gate_seed + +package store_sqlite + +// The deliberately bad reclaim policy is absent from production builds. +func editGateClosedReclaimSeed(*Store) {} diff --git a/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go b/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go new file mode 100644 index 000000000..2bcb8a7ec --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_disabled_test.go @@ -0,0 +1,21 @@ +//go:build !gortex_edit_gate_seed + +package store_sqlite + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateProductionBuildCannotEnableClosedSeed(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "closed-read-gate") + s, err := openPristine(t, filepath.Join(t.TempDir(), "production.sqlite")) + require.NoError(t, err) + defer s.Close() + before := s.WALReclaimStats() + release := s.AnnounceWrite() + defer release() + require.Equal(t, before.PauseCount, s.WALReclaimStats().PauseCount, "runtime environment cannot activate the test-only closed-gate policy") +} diff --git a/internal/graph/store_sqlite/edit_gate_seed_enabled.go b/internal/graph/store_sqlite/edit_gate_seed_enabled.go new file mode 100644 index 000000000..a5f8d1d06 --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_enabled.go @@ -0,0 +1,33 @@ +//go:build gortex_edit_gate_seed + +package store_sqlite + +import ( + "context" + "os" + "time" +) + +func editGateClosedReclaimSeed(s *Store) { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "closed-read-gate" || s.coreless() || s.readGate == nil { + return + } + db, err := openWALReclaimCheckpointDB(s.dbPath) + if err != nil { + return + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + cfg := resolveWALReclaimConfig() + // Force the existing closed-admission path even with a small/empty log; + // no WAL size or counter sample is fabricated by this selection fault. + cfg.readerWait, cfg.thresholdBytes = 0, -1 + res := walReclaimResult{urgent: true, bytesBefore: s.observedWALFileSize(s.dbPath + "-wal")} + _ = s.reclaimWALInLane(ctx, cfg, db, &res) + if res.pauseClosed { + s.walReclaim.update(func(stats *WALReclaimStats) { stats.notePause(res.pause) }) + s.walReclaim.pauseDrain.observe(res.pauseDrain) + s.walReclaim.pauseTruncate.observe(res.pauseTruncate) + } +} diff --git a/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go b/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go new file mode 100644 index 000000000..11e3f99f7 --- /dev/null +++ b/internal/graph/store_sqlite/edit_gate_seed_enabled_test.go @@ -0,0 +1,23 @@ +//go:build gortex_edit_gate_seed + +package store_sqlite + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateClosedSeedUsesRealAdmissionAndPauseCounter(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "closed-read-gate") + s, err := openPristine(t, filepath.Join(t.TempDir(), "seed.sqlite")) + require.NoError(t, err) + defer s.Close() + before := s.WALReclaimStats() + release := s.AnnounceWrite() + defer release() + require.True(t, s.editIntentActive(), "the forced reclaim belongs to an actual admitted write announcement") + after := s.WALReclaimStats() + require.Greater(t, after.PauseCount, before.PauseCount, "test-only policy must actually close read admission, not synthesize a report value") +} diff --git a/internal/graph/store_sqlite/wal_reclaim.go b/internal/graph/store_sqlite/wal_reclaim.go index 17a8801b4..8a6f6bf36 100644 --- a/internal/graph/store_sqlite/wal_reclaim.go +++ b/internal/graph/store_sqlite/wal_reclaim.go @@ -1280,7 +1280,9 @@ func (s *Store) writeWanted() bool { // mutation queued behind them upstream of the write gate is not held by them // either. The release is idempotent. func (s *Store) AnnounceWrite() (release func()) { - return s.announceWrite(true) + release = s.announceWrite(true) + editGateClosedReclaimSeed(s) + return release } // AnnounceCheckoutRefresh retains global mutation demand while a checkout diff --git a/internal/mcp/edit_gate_seed_disabled.go b/internal/mcp/edit_gate_seed_disabled.go new file mode 100644 index 000000000..0db9d9dd8 --- /dev/null +++ b/internal/mcp/edit_gate_seed_disabled.go @@ -0,0 +1,8 @@ +//go:build !gortex_edit_gate_seed + +package mcp + +import "context" + +// Production builds cannot activate the deliberately slow regression seed. +func editGateACKSeed(context.Context, *Server) error { return nil } diff --git a/internal/mcp/edit_gate_seed_disabled_test.go b/internal/mcp/edit_gate_seed_disabled_test.go new file mode 100644 index 000000000..36a9b832a --- /dev/null +++ b/internal/mcp/edit_gate_seed_disabled_test.go @@ -0,0 +1,17 @@ +//go:build !gortex_edit_gate_seed + +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditGateProductionBuildCannotEnableACKSeed(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.NoError(t, editGateACKSeed(ctx, nil), "production build has no timer, hook or environment activation") +} diff --git a/internal/mcp/edit_gate_seed_enabled.go b/internal/mcp/edit_gate_seed_enabled.go new file mode 100644 index 000000000..ef406bf6f --- /dev/null +++ b/internal/mcp/edit_gate_seed_enabled.go @@ -0,0 +1,35 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "time" +) + +// Only the dedicated test build contains this switch. The caller owns the +// existing reindex-wait lap; this helper must never introduce another lap. +var editGateSeedACKEntered func() + +func editGateACKSeed(ctx context.Context, s *Server) error { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "ack-wait" { + return nil + } + // The synthetic legacy wait yields its existing dispatch permit, just + // like an ordinary publication wait. It owns no writer or extra lap. + waiting := make(chan struct{}) + defer close(waiting) + s.releaseDispatchSlotForWait(ctx, waiting) + if editGateSeedACKEntered != nil { + editGateSeedACKEntered() + } + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/mcp/edit_gate_seed_enabled_test.go b/internal/mcp/edit_gate_seed_enabled_test.go new file mode 100644 index 000000000..743d17434 --- /dev/null +++ b/internal/mcp/edit_gate_seed_enabled_test.go @@ -0,0 +1,55 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedRunsInsideActualAdmittedWrite(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + srv, dir := setupTestServer(t) + published := make(chan indexer.MutationResult, 1) + published <- indexer.MutationResult{Reindexed: true} + close(published) + srv.SetWatcher(mutationTestWatcher{done: published}) + srv.mutationReindexWait = time.Nanosecond + entered := make(chan struct{}) + editGateSeedACKEntered = func() { close(entered) } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + req := mcplib.CallToolRequest{} + req.Params.Name = "edit_file" + req.Params.Arguments = map[string]any{"path": filepath.Join(dir, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"} + done := make(chan error, 1) + go func() { _, err := srv.runAcknowledgedEdit(ctx, req, srv.handleEditFile); done <- err }() + select { + case <-entered: + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("cancelled seed did not join the actual write handler") + } + case err := <-done: + raw, readErr := os.ReadFile(filepath.Join(dir, "main.go")) + require.NoError(t, readErr) + t.Fatalf("actual disk write returned before test seed admission: committed=%t error=%v", strings.Contains(string(raw), "println(1)"), err) + case <-ctx.Done(): + t.Fatal("neither actual write nor seed admitted") + } + raw, err := os.ReadFile(filepath.Join(dir, "main.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "println(1)", "seed cancellation must not undo the preceding physical commit") +} diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index a883ffdce..0c3728a11 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -593,6 +593,12 @@ func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutat waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) defer cancel() waitLap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + if seedErr := editGateACKSeed(ctx, s); seedErr != nil { + waitLap.finish() + outcome := receipt.outcome(true) + outcome.Err = errors.Join(outcome.Err, seedErr) + return outcome + } if !s.releaseDispatchSlotForWait(waitCtx, receipt.done) { waitLap.finish() return receipt.outcome(true) From 657f75c1a5fc46a4cb1ff73b09d7503c22963962 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:39:45 +0100 Subject: [PATCH 085/138] test(freshness): require explicit tier claims for opted-in reads --- .../mcp/freshness_explicit_contract_test.go | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/mcp/freshness_explicit_contract_test.go diff --git a/internal/mcp/freshness_explicit_contract_test.go b/internal/mcp/freshness_explicit_contract_test.go new file mode 100644 index 000000000..9d9ce490b --- /dev/null +++ b/internal/mcp/freshness_explicit_contract_test.go @@ -0,0 +1,100 @@ +package mcp + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/indexer" +) + +func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + for _, format := range []string{"json", "toon", "text", "gcx", "compact"} { + t.Run(scope+"/"+format, func(t *testing.T) { + s, _ := newRelationFixture(t) + s.SetWatcher(&watchedTierFixture{}) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "freshness_scope": scope, "format": format} + body := "callers[0]{id}:" + if format == "json" { + body = `{"callers":[],"complete":false}` + } else if format == "gcx" { + body = "GCX1 tool=get_callers\nrow1" + } else if format == "compact" { + req.Params.Arguments.(map[string]any)["compact"] = true + } + original := mcp.NewToolResultText(body) + got := s.decorateToolFreshness(t.Context(), req.Params.Name, req, original) + if format == "compact" { + require.Same(t, original, got, "compact has no new explicit-fresh contract") + return + } + if format == "json" { + payload := resultJSON(t, got) + fields, ok := payload["freshness"].(map[string]any) + require.True(t, ok, resultText(got)) + require.Equal(t, "fresh", fields["graph_freshness"]) + require.Equal(t, scope, fields["freshness_scope"]) + require.EqualValues(t, 0, fields["pending_file_count"]) + require.Equal(t, false, payload["complete"], "freshness must preserve an answer's completeness") + } else if format == "gcx" { + require.Contains(t, resultText(got), "graph_freshness=fresh") + require.Contains(t, resultText(got), "# "+scope+": fresh (0 files)\n") + } else { + require.True(t, strings.HasPrefix(resultText(got), scope+": fresh (0 files)\n"), resultText(got)) + } + }) + } + } +} + +func TestRequireFreshDoesNotInventGraphAuthority(t *testing.T) { + for _, route := range []string{"unwatched", "checkout", "source", "error"} { + t.Run(route, func(t *testing.T) { + s, _ := newRelationFixture(t) + ctx := t.Context() + if route != "unwatched" { + s.SetWatcher(&watchedTierFixture{}) + } + if route == "checkout" || route == "source" { + view := &requestView{reader: s.graph} + if route == "source" { + view.sourceScope = "file" + } + ctx = withRequestView(ctx, view) + } + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "format": "json"} + original := mcp.NewToolResultText(`{"callers":[]}`) + if route == "error" { + original.IsError = true + } + require.Same(t, original, s.decorateToolFreshness(ctx, req.Params.Name, req, original)) + }) + } +} + +func TestRequireFreshKeepsPreReadPublicationGap(t *testing.T) { + s, root := newRelationFixture(t) + watcher := &watchedTierFixture{paths: map[string]indexer.MutationTierState{ + filepath.Join(root, "target.go"): {Generation: 1, AckAt: time.Now()}, + }} + s.SetWatcher(watcher) + ctx := s.captureToolFreshness(t.Context()) + watcher.mu.Lock() + watcher.paths = nil + watcher.mu.Unlock() + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + fields := resultJSON(t, got)["freshness"].(map[string]any) + require.Equal(t, "pending", fields["graph_freshness"], "a completed gap cannot certify rows read before publication") +} From 3d2a40537c18e17f1d6841466546640f2b430926 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:49:44 +0100 Subject: [PATCH 086/138] fix(freshness): verify canonical source before explicit tier claims --- .../mcp/freshness_explicit_contract_test.go | 46 +++++++++++- internal/mcp/freshness_rider.go | 73 ++++++++++++++++++- internal/mcp/overlay.go | 4 + 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/internal/mcp/freshness_explicit_contract_test.go b/internal/mcp/freshness_explicit_contract_test.go index 9d9ce490b..33ff3d028 100644 --- a/internal/mcp/freshness_explicit_contract_test.go +++ b/internal/mcp/freshness_explicit_contract_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "path/filepath" "strings" "testing" @@ -9,18 +10,43 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" ) +func explicitFreshnessFixture(t *testing.T) (*Server, context.Context, func()) { + t.Helper() + stack := newViewStack(t) + require.NoError(t, stack.leases.RegisterRepositoryOwner(viewTestOwner())) + authority := indexer.NewOutputGenerationAuthority(stack.leases) + mutate := func() { + t.Helper() + receipt, err := authority.Begin(t.Context(), indexer.OutputEntryWatcherDirScan, indexer.OutputMutationTarget{ + Kind: indexer.OutputGenerationLegacy, OwnerKey: "root:" + stack.repoRoot, RepoPrefix: "repo", RootPath: stack.repoRoot, + }) + require.NoError(t, err) + stack.store.AddNode(viewRepoNode("repo/target.go::Target", "Target", graph.KindFunction, "repo/target.go", 1)) + require.NoError(t, receipt.Complete()) + } + mutate() + stack.srv.SetWatcher(&watchedTierFixture{}) + ctx := withRequestView(t.Context(), &requestView{reader: newBaseGraphReader(stack.store, "repo"), baseNarrowed: true}) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{"require_fresh": true} + ctx, release := stack.srv.captureExplicitFreshness(ctx, req) + require.NotNil(t, release) + t.Cleanup(release) + return stack.srv, ctx, mutate +} + func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { for _, scope := range []string{"file", "relations", "derived"} { for _, format := range []string{"json", "toon", "text", "gcx", "compact"} { t.Run(scope+"/"+format, func(t *testing.T) { - s, _ := newRelationFixture(t) - s.SetWatcher(&watchedTierFixture{}) + s, ctx, _ := explicitFreshnessFixture(t) req := mcp.CallToolRequest{} req.Params.Name = "get_callers" - req.Params.Arguments = map[string]any{"id": targetID(t, s), "require_fresh": true, "freshness_scope": scope, "format": format} + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "freshness_scope": scope, "format": format} body := "callers[0]{id}:" if format == "json" { body = `{"callers":[],"complete":false}` @@ -30,7 +56,7 @@ func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { req.Params.Arguments.(map[string]any)["compact"] = true } original := mcp.NewToolResultText(body) - got := s.decorateToolFreshness(t.Context(), req.Params.Name, req, original) + got := s.decorateToolFreshness(ctx, req.Params.Name, req, original) if format == "compact" { require.Same(t, original, got, "compact has no new explicit-fresh contract") return @@ -54,6 +80,18 @@ func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { } } +func TestRequireFreshRejectsCompletedMutationDuringRead(t *testing.T) { + s, ctx, mutate := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + before := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "fresh", resultJSON(t, before)["freshness"].(map[string]any)["graph_freshness"]) + mutate() // The accepted publication completes; no pending path survives. + after := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, after)["freshness"].(map[string]any)["graph_freshness"]) +} + func TestRequireFreshDoesNotInventGraphAuthority(t *testing.T) { for _, route := range []string{"unwatched", "checkout", "source", "error"} { t.Run(route, func(t *testing.T) { diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 95ff7a3c3..8e162e56c 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -15,6 +15,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" ) @@ -527,7 +528,7 @@ func renderFreshnessLine(res *mcp.CallToolResult, fields map[string]any) *mcp.Ca } state, scope = "pending", "file" } - if state == "fresh" || state == "" { + if state == "" { return res } if scope == "" { @@ -586,6 +587,11 @@ func (s *Server) freshnessFor(ctx context.Context, scope string, ids, files []st } states := make(map[string]pathState) put := func(path, state string, generation uint64) { + if len(ids)+len(files) == 0 { + if prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)); prefix != "" && !strings.HasPrefix(s.repoRelative(path), prefix+"/") { + return + } + } if len(ids)+len(files) > 0 && !inScope(path) { return } @@ -697,6 +703,12 @@ func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, return "file", nil, []string{file} } switch name { + case "search_symbols", "search_text": + // A query is not a file identity. An explicit tier answer for these + // tools covers the selected canonical repository's whole graph. + if requestBoolArg(&req, requireFreshArgName) && !isCompact(req) { + return "relations", nil, nil + } case "verify_change": var changes []struct { SymbolID string `json:"symbol_id"` @@ -720,7 +732,7 @@ func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, if len(ids) > 0 { return "relations", ids, nil } - case "get_callers", "find_usages", "get_call_chain", "get_dependencies", "get_dependents", "find_implementations", "walk_graph": + case "get_callers", "find_usages", "get_call_chain", "trace_call_chain", "get_dependencies", "get_dependents", "find_implementations", "walk_graph": for _, key := range []string{"id", "symbol_id", "start_id"} { if id := req.GetString(key, ""); id != "" { ids = append(ids, id) @@ -735,11 +747,12 @@ 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 { scope, ids, files := freshnessTargets(name, req) + var label relationFreshness if scope != "" { if selected := takeRequestFreshness(&req).scope; selected != "" { scope = selected } - label := s.freshnessFor(ctx, scope, ids, files) + label = s.freshnessFor(ctx, scope, ids, files) if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) } @@ -750,15 +763,64 @@ func (s *Server) decorateToolFreshness(ctx context.Context, name string, req mcp res = decorateResultWithFreshness(res, fields) } } + beforeFileRiders := res if rider := s.freshnessRiderFor(name, req); rider != nil { res = decorateResultWithFreshness(res, rider) } if isFreshnessListTool(name) { res = s.decorateListResultWithFreshness(res) } + // A disk-drift or missing-file rider must not be joined to a fresh claim. + // Keep the producer's internal zero state unchanged; only an opted-in + // successful response renders its explicit equivalent. + if scope != "" && label.state == "" && res != nil && !res.IsError && res == beforeFileRiders { + label = s.explicitFreshness(ctx, req, label) + if label.state != "" { + fields := make(map[string]any) + label.apply(fields) + res = decorateResultWithFreshness(res, fields) + } + } return fitToolFreshnessBudget(ctx, req, res) } +func (s *Server) explicitFreshness(ctx context.Context, req mcp.CallToolRequest, label relationFreshness) relationFreshness { + if label.state != "" || !requestBoolArg(&req, requireFreshArgName) || isCompact(req) { + return label + } + // Absence of a local receipt is not proof for another checkout, a source + // fallback, an immutable view, or an unwatched graph. Route freshness is + // a different claim and cannot provide this tier authority. + if _, watched := s.canonicalTierPaths(ctx); watched { + label.state = "unknown" + if pin, ok := ctx.Value(explicitFreshnessPinKey{}).(*graphview.BasePin); ok && pin.ValidateAcceptedCurrent() == nil { + label.state = "fresh" + } + } + return label +} + +type explicitFreshnessPinKey struct{} + +// The accepted source witness belongs to the canonical repository that this +// request's reader actually filters. It is captured before the read, released +// with the request, and checked again when that read's tier label is rendered. +func (s *Server) captureExplicitFreshness(ctx context.Context, req mcp.CallToolRequest) (context.Context, func()) { + if !requestBoolArg(&req, requireFreshArgName) || isCompact(req) || s.materializer == nil || s.materializer.Leases == nil { + return ctx, nil + } + prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)) + if prefix == "" { + return ctx, nil + } + pin := s.materializer.Leases.AcquireBaseCorpus(prefix) + if pin.ValidateAcceptedCurrent() != nil { + pin.Release() + return ctx, nil + } + return context.WithValue(ctx, explicitFreshnessPinKey{}, pin), pin.Release +} + type toolFreshnessBudgetKey struct{} // Reserve the rider before the existing per-shape fitters choose rows. The @@ -782,6 +844,7 @@ func (s *Server) reserveToolFreshnessBudget(ctx context.Context, name string, re if snapshot, ok := ctx.Value(toolFreshnessSnapshotKey{}).(toolFreshnessSnapshot); ok { label = s.freshnessFor(ctx, scope, ids, files, snapshot.receipts) } + label = s.explicitFreshness(ctx, req, label) if label.state == "" { return ctx, req } @@ -899,7 +962,9 @@ func (f relationFreshness) apply(result map[string]any) { if f.state == "" { return } - result["complete"] = false + if f.state != "fresh" { + result["complete"] = false + } result["graph_freshness"] = f.state scope := f.scope if scope == "" { diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 49f83cf4c..6aa059264 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -357,6 +357,10 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withContractCoreReadErrors(ctx) } indexer.StampPublicationPhase(ctx, indexer.PublicationHandlerStarted) + ctx, releaseExplicitFreshness := s.captureExplicitFreshness(ctx, req) + if releaseExplicitFreshness != nil { + defer releaseExplicitFreshness() + } ctx = s.captureToolFreshness(ctx) ctx, req = s.reserveToolFreshnessBudget(ctx, req.Params.Name, req) res, hErr := s.invokeAcknowledgedLeaf(ctx, req, h) From ba4266d04cd015e40253fe566313d3784e4bf0b4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:50:34 +0100 Subject: [PATCH 087/138] test: route edit gate faults through owned tagged daemons --- bench/load/edit_gate_seed.go | 34 +++++++++ bench/load/edit_gate_seed_test.go | 80 ++++++++++++++++++++ bench/load/paired_command.go | 3 + bench/load/plan.go | 1 + bench/load/run.go | 6 ++ bench/load/scenario.go | 6 +- bench/load/testdata/paired-v1.schema.json | 3 + bench/load/testdata/report-v1.schema.json | 3 + internal/mcp/edit_gate_seed_checkout_test.go | 63 +++++++++++++++ internal/mcp/edit_gate_seed_disabled.go | 2 + internal/mcp/edit_gate_seed_enabled.go | 12 +++ internal/mcp/edit_serialization.go | 6 +- 12 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 bench/load/edit_gate_seed.go create mode 100644 bench/load/edit_gate_seed_test.go create mode 100644 internal/mcp/edit_gate_seed_checkout_test.go diff --git a/bench/load/edit_gate_seed.go b/bench/load/edit_gate_seed.go new file mode 100644 index 000000000..1a7905152 --- /dev/null +++ b/bench/load/edit_gate_seed.go @@ -0,0 +1,34 @@ +package main + +import ( + "debug/buildinfo" + "errors" + "fmt" + "strings" +) + +// Faults are reachable only through the explicit standalone gate option and a +// binary built with the seed tag. Ambient GORTEX_* values remain filtered out. +func editGateSeedEnvironment(o runOptions) ([]string, error) { + if o.EditGateSeed == "" { + return nil, nil + } + if err := validateRunOptions(o); err != nil { + return nil, err + } + info, err := buildinfo.ReadFile(o.Binary) + if err != nil { + return nil, fmt.Errorf("read edit gate seed binary metadata: %w", err) + } + for _, setting := range info.Settings { + if setting.Key != "-tags" { + continue + } + for tag := range strings.FieldsFuncSeq(setting.Value, func(r rune) bool { return r == ',' || r == ' ' }) { + if tag == "gortex_edit_gate_seed" { + return []string{"GORTEX_EDIT_GATE_SEED=" + o.EditGateSeed}, nil + } + } + } + return nil, errors.New("edit gate fault requires binary built with gortex_edit_gate_seed tag") +} diff --git a/bench/load/edit_gate_seed_test.go b/bench/load/edit_gate_seed_test.go new file mode 100644 index 000000000..20a72a0f8 --- /dev/null +++ b/bench/load/edit_gate_seed_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestEditGateSeedExplicitOptIn(t *testing.T) { + base := []string{"--binary", "unused", "--manifest", "unused", "--scratch", t.TempDir(), "--scenario", editProbeScenario, "--agents", "0", "--edit-agents", "4", "--worktrees", "0"} + for _, mode := range []string{"ack-wait", "closed-read-gate"} { + t.Run(mode, func(t *testing.T) { + args := append(append([]string{}, base...), "--edit-gate-modes", "--edit-gate-seed", mode) + if _, err := parseRun(args); err != nil { + t.Fatalf("explicit gate-only seed unavailable: %v", err) + } + }) + } + for _, args := range [][]string{ + append(append([]string{}, base...), "--edit-gate-seed", "ack-wait"), + append(append([]string{}, base...), "--edit-gate-modes", "--edit-gate-seed", "unregistered"), + } { + if _, err := parseRun(args); err == nil { + t.Fatal("seed accepted outside the explicit closed gate contract") + } + } + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + for _, entry := range privateEnv(t.TempDir(), "owned.sock", "owned.gitconfig") { + if strings.HasPrefix(entry, "GORTEX_EDIT_GATE_SEED=") { + t.Fatal("ambient seed leaked into ordinary private daemon") + } + } +} + +func TestEditGateSeedVerifiesActualBinaryAndOwnsEnvironment(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0600); err != nil { + t.Fatal(err) + } + options := runOptions{Binary: filepath.Join(dir, "seed-bin"), Manifest: "unused", Scratch: dir, Scenario: editProbeScenario, Editors: 4, EditCount: 100, EditGateModes: true, EditGateSeed: "ack-wait", Duration: 1, Deadline: 1, WarmTimeout: 1, StopTimeout: 1, Profile: "agent", SweepRepetitions: 1, SweepBudget: 1} + for _, tags := range []string{"gortex_edit_gate_seed_suffix", "", "extra,gortex_edit_gate_seed"} { + cmd := exec.CommandContext(t.Context(), "go", "build", "-tags="+tags, "-o", options.Binary, filepath.Join(dir, "main.go")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build metadata fixture: %v: %s", err, out) + } + env, err := editGateSeedEnvironment(options) + if tags == "extra,gortex_edit_gate_seed" { + if err != nil || !reflect.DeepEqual(env, []string{"GORTEX_EDIT_GATE_SEED=ack-wait"}) { + t.Fatalf("tagged binary seed environment=%v err=%v", env, err) + } + } else if err == nil || len(env) != 0 { + t.Fatalf("untagged/prefix-only binary accepted seed: %v %v", env, err) + } + } + options.Binary = "missing" + options.EditGateSeed = "" + if env, err := editGateSeedEnvironment(options); err != nil || len(env) != 0 { + t.Fatalf("ordinary run unexpectedly inspects metadata or enables a seed: %v %v", env, err) + } + options.EditGateSeed = "ack-wait" + if _, err := editGateSeedEnvironment(options); err == nil { + t.Fatal("missing executable accepted") + } +} + +func TestEditGateSeedRefusesBeforeAllocatingPrivateState(t *testing.T) { + options := runOptions{Binary: "missing", Manifest: "unused", Scratch: t.TempDir(), Scenario: editProbeScenario, Editors: 4, EditCount: 100, EditGateModes: true, EditGateSeed: "ack-wait", Duration: 1, Deadline: 1, WarmTimeout: 1, StopTimeout: 1, Profile: "agent", SweepRepetitions: 1, SweepBudget: 1} + root := t.TempDir() + _, _, artifacts, _, _, err := runPhase(t.Context(), options, editProbeScenario, nil, "", root) + if err == nil || len(artifacts) != 0 { + t.Fatalf("untagged seed reached private state: artifacts=%v err=%v", artifacts, err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("refused seed allocated state: %v %v", entries, err) + } +} diff --git a/bench/load/paired_command.go b/bench/load/paired_command.go index 4125b3926..c21977df8 100644 --- a/bench/load/paired_command.go +++ b/bench/load/paired_command.go @@ -50,6 +50,9 @@ func parsePaired(args []string) (runOptions, []buildSpec, error) { return o, nil, err } o.Binary = builds[0].binary + if o.EditGateSeed != "" { + return o, nil, errors.New("edit gate fault seeds are standalone candidate runs only") + } if err := validateRunOptions(o); err != nil { return o, nil, err } diff --git a/bench/load/plan.go b/bench/load/plan.go index d17afa478..79a36810a 100644 --- a/bench/load/plan.go +++ b/bench/load/plan.go @@ -63,6 +63,7 @@ type request struct { // Workload is the complete normalized offered load. Binary paths, private // clone paths and revisions belong to the run identity, never the plan hash. type Workload struct { + EditGateSeed string `json:"edit_gate_seed,omitempty"` PRSmokeDrain *PRSmokeDrain `json:"pr_smoke_drain,omitempty"` Scenario string `json:"scenario"` Agents int `json:"agents"` diff --git a/bench/load/run.go b/bench/load/run.go index cce5dd870..c43f18684 100644 --- a/bench/load/run.go +++ b/bench/load/run.go @@ -41,6 +41,7 @@ type runOptions struct { EditMean time.Duration EditCount int EditGateModes bool + EditGateSeed string EditRepos int EditProbeRole string editProbeBaseline bool @@ -95,10 +96,14 @@ func bindRunFlags(f *flag.FlagSet, o *runOptions) { f.IntVar(&o.EditRepos, "edit-repos", 1, "edit-and-probe: one repository (default), or exactly six distinct pinned repositories") f.IntVar(&o.EditCount, "edit-count", 100, "edit-and-probe: 100 logical edits, or explicit 1-edit diagnostic") f.BoolVar(&o.EditGateModes, "edit-gate-modes", false, "edit-and-probe gate: rotate five probe surfaces over 100 total edits; JSON ACKs remain unchanged") + f.StringVar(&o.EditGateSeed, "edit-gate-seed", "", "explicit gate-only fault: ack-wait or closed-read-gate; requires a seed-tagged binary") f.StringVar(&o.EditProbeRole, "edit-probe-role", "", "edit-and-probe standalone role: candidate (default; phase accounting required) or baseline (absent phase timings reported unavailable)") } func validateRunOptions(o runOptions) error { + if o.EditGateSeed != "" && (!o.EditGateModes || o.EditGateSeed != "ack-wait" && o.EditGateSeed != "closed-read-gate") { + return errors.New("--edit-gate-seed requires --edit-gate-modes and ack-wait or closed-read-gate") + } if o.EditGateModes && (o.Scenario != editProbeScenario || o.EditCount != 100 || o.Agents != 0) { return errors.New("--edit-gate-modes requires edit-and-probe, 100 total edits and no extra pure readers") } @@ -296,6 +301,7 @@ func newRunReport(o runOptions, corpora []Corpus, sha string, host Host, outputR } report.Workload.SessionModel = plan.SessionModel } + report.Workload.EditGateSeed = o.EditGateSeed report.Workload.Scenario, report.Workload.Agents, report.Workload.EditAgents, report.Workload.LinkedWorktrees = o.Scenario, o.Agents, o.Editors, o.Worktrees report.Workload.DurationSec, report.Workload.DeadlineSec, report.Workload.Seed, report.Workload.Profile = o.Duration.Seconds(), o.Deadline.Seconds(), o.Seed, o.Profile if o.PRSmokeDrain > 0 { diff --git a/bench/load/scenario.go b/bench/load/scenario.go index 54cd8b19e..96471659b 100644 --- a/bench/load/scenario.go +++ b/bench/load/scenario.go @@ -14,6 +14,10 @@ import ( ) func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corpus, group, outputRoot string) (phase Phase, sweep []SweepEntry, artifacts map[string]string, crashes map[string]int, version string, resultErr error) { + seedEnv, err := editGateSeedEnvironment(o) + if err != nil { + return phase, nil, nil, nil, "", err + } var gateWallStart time.Time if o.EditGateModes { gateWallStart = time.Now() @@ -127,7 +131,6 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp phase.SessionModel = plan.SessionModel } p := o.smokeState - var err error if p == nil { p, err = newPrivateRun(ctx, outputRoot, o.Binary, selected) } else { @@ -136,6 +139,7 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp if err != nil { return phase, nil, artifacts, crashes, "", err } + p.Env = append(p.Env, seedEnv...) artifacts["private_root"] = p.Root firstStart := p.startCount + 1 if o.smokeState != nil { diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index b17ba87bd..1bf57f6ba 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -3775,6 +3775,9 @@ "edit_agents": { "type": "integer" }, + "edit_gate_seed": { + "type": "string" + }, "edit_probe": { "anyOf": [ { diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index 5feaa97ea..e9e4800e1 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -3762,6 +3762,9 @@ "edit_agents": { "type": "integer" }, + "edit_gate_seed": { + "type": "string" + }, "edit_probe": { "anyOf": [ { diff --git a/internal/mcp/edit_gate_seed_checkout_test.go b/internal/mcp/edit_gate_seed_checkout_test.go new file mode 100644 index 000000000..7632ae8cd --- /dev/null +++ b/internal/mcp/edit_gate_seed_checkout_test.go @@ -0,0 +1,63 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedReachesActualFacadeCheckoutWrite(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + after := strings.Replace(string(before), "func helper() {}", "func helper() { println(1) }", 1) + require.NotEqual(t, string(before), after) + sum := sha256.Sum256([]byte(after)) + results := make(chan indexer.MutationResult, 1) + results <- indexer.MutationResult{Reindexed: true} + close(results) + mutation := &receiptCheckoutMutation{ticket: &indexer.CheckoutRefreshTicket{CheckoutID: "checkout-seed", Incarnation: "incarnation-seed", Root: dir, RepoPrefix: "fixture", ContentHash: hex.EncodeToString(sum[:]), Ticket: &indexer.MutationTicket{Path: path, Generation: 7, Done: results}}} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + ctx = withCheckoutMutation(WithSessionID(ctx, "seed-facade"), mutation, dir) + // Negotiate and dispatch the actual facade shape used by the load harness. + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"integration-harness","version":"1.0"}}}`))) + frame, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "edit", "arguments": map[string]any{"operation": "file", "target": map[string]any{"file": "main.go"}, "match": "func helper() {}", "replacement": "func helper() { println(1) }", "output": map[string]any{"format": "json"}}}}) + require.NoError(t, err) + entered := make(chan struct{}) + editGateSeedACKEntered = func() { close(entered) } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + done := make(chan any, 1) + go func() { done <- srv.MCPServer().HandleMessage(ctx, frame) }() + select { + case <-entered: + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("cancelled checkout seed did not join handler") + } + case reply := <-done: + raw, readErr := os.ReadFile(path) + require.NoError(t, readErr) + t.Fatalf("facade checkout returned before seed; committed=%t reply=%+v", strings.Contains(string(raw), "println(1)"), reply) + case <-ctx.Done(): + t.Fatal("checkout write neither admitted seed nor returned") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, after, string(raw)) +} diff --git a/internal/mcp/edit_gate_seed_disabled.go b/internal/mcp/edit_gate_seed_disabled.go index 0db9d9dd8..b54c046f3 100644 --- a/internal/mcp/edit_gate_seed_disabled.go +++ b/internal/mcp/edit_gate_seed_disabled.go @@ -6,3 +6,5 @@ import "context" // Production builds cannot activate the deliberately slow regression seed. func editGateACKSeed(context.Context, *Server) error { return nil } + +func editGateCheckoutACKSeed(context.Context, *Server) error { return nil } diff --git a/internal/mcp/edit_gate_seed_enabled.go b/internal/mcp/edit_gate_seed_enabled.go index ef406bf6f..5338c67b0 100644 --- a/internal/mcp/edit_gate_seed_enabled.go +++ b/internal/mcp/edit_gate_seed_enabled.go @@ -33,3 +33,15 @@ func editGateACKSeed(ctx context.Context, s *Server) error { return nil } } + +// The checkout adapter fast-ACKs an admitted ticket without a normal graph +// wait. Only a seed build records the deliberately restored legacy wait here. +// When admission/await are split, this belongs after the source path is released. +func editGateCheckoutACKSeed(ctx context.Context, s *Server) error { + if os.Getenv("GORTEX_EDIT_GATE_SEED") != "ack-wait" { + return nil + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + defer lap.finish() + return editGateACKSeed(ctx, s) +} diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index 0c3728a11..61ccb8650 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -552,7 +552,11 @@ func (s *Server) mutationWaitDuration() time.Duration { // servers have no watcher and retain the synchronous freshness contract. func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { if state := checkoutMutationFromContext(ctx); state != nil { - return s.refreshCheckoutMutation(ctx, absPath, state) + outcome := s.refreshCheckoutMutation(ctx, absPath, state) + if outcome.Err == nil && outcome.Pending && outcome.Receipt != "" { + outcome.Err = editGateCheckoutACKSeed(ctx, s) + } + return outcome } if watcher := s.currentWatcher(); watcher != nil { // Admission is path-scoped and authoritative. Scheduling uses a detached From 40204b6662348cb2474126bdf8912266eaafc0c4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:53:22 +0100 Subject: [PATCH 088/138] feat: retain explicit edit fault tasks and unbudgeted route observations --- bench/check-budgets/edit_loop.go | 53 +++++++++ bench/check-budgets/edit_loop_lane.go | 12 +- bench/check-budgets/edit_loop_seed_test.go | 124 +++++++++++++++++++++ bench/check-budgets/lane.go | 24 ++-- 4 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 bench/check-budgets/edit_loop_seed_test.go diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index ff90cfa6c..df992c60c 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -139,6 +139,13 @@ func editLoopCommand(args []string, stdout, stderr io.Writer) int { } rows = append(rows, row) } + if *scenario == "multirepoedit-6" { + row := assessSixRepoReadObservation(dataset, loadErr) + rows = append(rows, row) + if editLoopRowBlocks(row) { + pass = false + } + } writer := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(writer, "scenario\tmetric\tvalue\tbudget\tunit\tbaseline\tdelta\tstate\treason") for _, row := range rows { @@ -441,6 +448,9 @@ func assessCompactEditLoopRow(row editLoopRow, b budget, d dataset) editLoopRow } func editLoopRowBlocks(row editLoopRow) bool { + if row.Scenario == "multirepoedit-6" && row.Metric == "readback_p95" && row.State == "OBSERVATIONAL" && row.Budget == nil && row.Value != nil && finiteNonnegative(*row.Value) && row.Unit == "s" { + return false + } return row.State != "PASS" && !(row.Metric == "silent_stale_compact" && row.State == "UNOBSERVABLE" && row.Value == nil) } @@ -554,3 +564,46 @@ func measureEditLoopRow(phase phase, name string) (float64, error) { } return values[(95*len(values)+99)/100-1], nil } + +// This population has no dedicated p95 target in the accepted contract. Retain +// its actual value without borrowing a single-repository limit or a PASS. +func assessSixRepoReadObservation(d dataset, loadErr error) editLoopRow { + row := editLoopRow{Scenario: "multirepoedit-6", Metric: "readback_p95", State: "UNAVAILABLE", Unit: "s"} + if loadErr != nil { + row.Reason = loadErr.Error() + return row + } + values := []float64{} + for repeat := 1; repeat <= d.config.repeats(row.Scenario); repeat++ { + found := false + for _, sample := range d.runs { + if sample.input.Role != "candidate" || sample.input.HostClass != "reference_linux" || sample.input.Scenario != row.Scenario || sample.input.Repeat != repeat { + continue + } + if found { + row.Reason = "duplicate candidate repeat" + return row + } + found = true + value, err := measureEditLoopRow(sample.data.Phases[0], row.Metric) + if err != nil { + row.Reason = err.Error() + return row + } + values = append(values, value) + } + if !found { + row.Reason = "missing candidate repeat" + return row + } + } + if len(values) == 0 { + row.Reason = "no actual observations" + return row + } + value := slices.Max(values) + row.Value = &value + row.State = "OBSERVATIONAL" + row.Reason = "unbudgeted: no dedicated six-repository p95 target; universal readback max remains blocking" + return row +} diff --git a/bench/check-budgets/edit_loop_lane.go b/bench/check-budgets/edit_loop_lane.go index 9f95374b5..3438d6602 100644 --- a/bench/check-budgets/edit_loop_lane.go +++ b/bench/check-budgets/edit_loop_lane.go @@ -15,6 +15,9 @@ import ( // it is not compressed to fit the smoke-only deadline. func makeEditLoopLanePlan(p policy, c laneConfig) (lanePlan, error) { plan := lanePlan{Lane: c.Lane, Execution: "edit-loop", HostClass: c.HostClass, Ready: true, PolicySHA256: policyDigest(p), Prerequisites: []string{}} + if c.EditGateSeed != "" && (c.Lane != "smoke" || c.EditGateSeed != "ack-wait" && c.EditGateSeed != "closed-read-gate") { + return plan, errors.New("edit gate faults are closed smoke-only modes") + } if c.HostClass != "reference_linux" || c.Lane != "smoke" && c.Lane != "nightly" { return plan, errors.New("edit gates require the reference Linux host and smoke or nightly lane") } @@ -27,7 +30,7 @@ func makeEditLoopLanePlan(p policy, c laneConfig) (lanePlan, error) { } for _, scenario := range scenarios { for repeat := 1; repeat <= max(3, c.Repeats); repeat++ { - task := laneTask{Scenario: scenario, Role: "candidate", Repeat: repeat, Seed: int64(repeat), EditGate: true, EditCount: 100, ProbeModes: []string{"json", "toon", "compact", "facade", "cli"}} + task := laneTask{EditGateSeed: c.EditGateSeed, Scenario: scenario, Role: "candidate", Repeat: repeat, Seed: int64(repeat), EditGate: true, EditCount: 100, ProbeModes: []string{"json", "toon", "compact", "facade", "cli"}} if scenario == "loadedit-4-largefile" && c.LargeFileManifest == "" { task.Unavailable = "large-file gate needs actual >=10000-line target admission; a scenario alias is not evidence" } @@ -67,6 +70,13 @@ func finishEditLoopLaneEvidence(c laneConfig, p policy, plan lanePlan, out strin } rows = append(rows, row) } + if scenarios["multirepoedit-6"] { + row := assessSixRepoReadObservation(d, loadErr) + rows = append(rows, row) + if editLoopRowBlocks(row) { + pass = false + } + } var table bytes.Buffer if err := printEditLoopRows(io.MultiWriter(stdout, &table), rows); err != nil { failures = append(failures, err) diff --git a/bench/check-budgets/edit_loop_seed_test.go b/bench/check-budgets/edit_loop_seed_test.go new file mode 100644 index 000000000..a651c2a21 --- /dev/null +++ b/bench/check-budgets/edit_loop_seed_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func editLoopSeedConfig(t *testing.T, seed, lane string) string { + t.Helper() + config := fixtureLaneConfig() + config.HostClass, config.Lane = "reference_linux", lane + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + wire["edit_gate_seed"] = seed + data, err = json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestEditLoopCollectorSeedUsesExplicitTaskAndActualProcessArguments(t *testing.T) { + for _, mode := range []string{"ack-wait", "closed-read-gate"} { + t.Run(mode, func(t *testing.T) { + path := editLoopSeedConfig(t, mode, "smoke") + config, err := readLaneConfig(path, true) + if err != nil { + t.Fatalf("explicit seed configuration cannot reach collector: %v", err) + } + plan, err := makeEditLoopLanePlan(policy{}, config) + if err != nil { + t.Fatal(err) + } + if len(plan.Tasks) != 3 { + t.Fatalf("seed lost three consecutive runs: %v", plan.Tasks) + } + for _, task := range plan.Tasks { + data, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + if wire["edit_gate_seed"] != mode { + t.Fatalf("actual task lost fault identity: %s", data) + } + args := taskArgs(config, task, "manifest", "scratch", "report") + index := slices.Index(args, "--edit-gate-seed") + if index < 0 || index+1 >= len(args) || args[index+1] != mode { + t.Fatalf("actual process cannot activate fault: %v", args) + } + } + if _, err := readLaneConfig(path); err == nil { + t.Fatal("ordinary collector accepted edit gate fault") + } + }) + } + for _, cell := range []struct{ seed, lane string }{{"unregistered", "smoke"}, {"ack-wait", "nightly"}} { + if _, err := readLaneConfig(editLoopSeedConfig(t, cell.seed, cell.lane), true); err == nil { + t.Fatal("fault accepted outside the closed smoke-only contract") + } + } +} + +func TestEditLoopSixRepoReadP95RemainsVisibleWithoutInventedBudget(t *testing.T) { + var out, diagnostics bytes.Buffer + code := run([]string{"edit-loop", "--budgets", "../budgets.yaml", "--scenario", "multirepoedit-6", "--inputs", filepath.Join(t.TempDir(), "absent.json")}, &out, &diagnostics) + if code != 1 { + t.Fatalf("missing evidence exit%d, want1", code) + } + found := false + for _, line := range strings.Split(out.String(), "\n") { + if !strings.Contains(line, "readback_p95") { + continue + } + found = true + if !strings.Contains(line, "UNAVAILABLE") || strings.Contains(line, "PASS") { + t.Fatalf("unmeasured six-repo observation claims a verdict: %s", line) + } + } + if !found { + t.Fatal("six-repository edit plus one-read p95 disappeared instead of being explicitly unbudgeted") + } +} + +func TestSixRepoObservationUsesActualRepeatedSamplesWithoutThreshold(t *testing.T) { + d := dataset{config: inputs{Repeats: 3}} + for repeat := 1; repeat <= 3; repeat++ { + d.runs = append(d.runs, observation{input: runInput{Role: "candidate", HostClass: "reference_linux", Scenario: "multirepoedit-6", Repeat: repeat}, data: editLoopEvidenceFixture(t)}) + } + row := assessSixRepoReadObservation(d, nil) + if row.State != "OBSERVATIONAL" || row.Value == nil || *row.Value != .1 || row.Budget != nil || editLoopRowBlocks(row) { + t.Fatalf("observed p95 acquired an invented verdict: %+v", row) + } + row.Metric = "readback_max" + if !editLoopRowBlocks(row) { + t.Fatal("observation qualification weakened universal hard cap") + } + row = assessSixRepoReadObservation(dataset{config: d.config, runs: d.runs[:2]}, nil) + if row.State != "UNAVAILABLE" || row.Value != nil || !editLoopRowBlocks(row) { + t.Fatalf("missing repeat became a numerical observation: %+v", row) + } + d.runs = append(d.runs, d.runs[0]) + if row := assessSixRepoReadObservation(d, nil); row.State != "UNAVAILABLE" || row.Value != nil { + t.Fatalf("duplicate repeat credited twice: %+v", row) + } +} diff --git a/bench/check-budgets/lane.go b/bench/check-budgets/lane.go index f92c94c96..09be13ec8 100644 --- a/bench/check-budgets/lane.go +++ b/bench/check-budgets/lane.go @@ -41,6 +41,7 @@ type laneBuild struct { } type laneConfig struct { + EditGateSeed string `json:"edit_gate_seed,omitempty"` Version int `json:"schema_version"` Lane string `json:"lane"` HostClass string `json:"host_class"` @@ -62,14 +63,15 @@ type laneConfig struct { } type laneTask struct { - EditGate bool `json:"edit_gate,omitempty"` - EditCount int `json:"edit_count,omitempty"` - ProbeModes []string `json:"probe_modes,omitempty"` - Scenario string `json:"scenario"` - Role string `json:"role"` - Repeat int `json:"repeat"` - Seed int64 `json:"seed"` - Unavailable string `json:"unavailable,omitempty"` + EditGateSeed string `json:"edit_gate_seed,omitempty"` + EditGate bool `json:"edit_gate,omitempty"` + EditCount int `json:"edit_count,omitempty"` + ProbeModes []string `json:"probe_modes,omitempty"` + Scenario string `json:"scenario"` + Role string `json:"role"` + Repeat int `json:"repeat"` + Seed int64 `json:"seed"` + Unavailable string `json:"unavailable,omitempty"` } type lanePlan struct { @@ -117,6 +119,9 @@ func readLaneConfig(path string, editGate ...bool) (laneConfig, error) { if d.Decode(new(any)) != io.EOF { return c, errors.New("lane config must contain exactly one JSON document") } + if c.EditGateSeed != "" && (len(editGate) == 0 || !editGate[0] || c.Lane != "smoke" || c.EditGateSeed != "ack-wait" && c.EditGateSeed != "closed-read-gate") { + return c, errors.New("edit_gate_seed requires the explicit edit smoke collector and ack-wait or closed-read-gate") + } if c.Version != 1 || !slices.Contains([]string{"smoke", "nightly", "release"}, c.Lane) || c.Repeats < 3 || c.Repeats > 100 || c.Machine == "" && c.HostClass != hostedSmokeClass { return c, errors.New("lane config needs schema1, smoke/nightly/release, 3–100 repeats and reviewed machine binding") } @@ -465,6 +470,9 @@ func taskArgs(c laneConfig, task laneTask, manifest, scratch, report string) []s if task.EditGate { args = append(args, "--edit-gate-modes", "--edit-count", "100", "--edit-repos", fmt.Sprint(spec.repos)) } + if task.EditGate && task.EditGateSeed != "" { + args = append(args, "--edit-gate-seed", task.EditGateSeed) + } if c.Lane == "smoke" && spec.wire == "read" && c.PRSmokeDrain != "" { args = append(args, "--pr-smoke-drain", c.PRSmokeDrain) } From 35d90a966c4ff6c3028001f27adc665377365cdf Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 21:56:09 +0100 Subject: [PATCH 089/138] ci: wire strict owned reference edit-loop gates --- .github/workflows/edit-loop-gate.yml | 142 ++++++++++++++++++ .../check-budgets/edit_loop_workflow_test.go | 67 +++++++++ 2 files changed, 209 insertions(+) create mode 100644 .github/workflows/edit-loop-gate.yml create mode 100644 bench/check-budgets/edit_loop_workflow_test.go diff --git a/.github/workflows/edit-loop-gate.yml b/.github/workflows/edit-loop-gate.yml new file mode 100644 index 000000000..e53c67589 --- /dev/null +++ b/.github/workflows/edit-loop-gate.yml @@ -0,0 +1,142 @@ +name: Edit-loop reference gates + +on: + schedule: + - cron: '41 2 * * *' + workflow_dispatch: + inputs: + lane: + description: Reference lane (three consecutive runs per cell) + type: choice + options: [smoke, nightly] + default: smoke + seed: + description: Test-only regression; the selected row must fail + type: choice + options: [none, ack-wait, closed-read-gate] + default: none + +permissions: + contents: read + +# Requires a separately registered owned reference runner and reviewed cgroup/host bindings. +# Never cancel a run while its owned daemon still needs shutdown receipts. +concurrency: + group: edit-loop-reference + cancel-in-progress: false + +env: + CGO_ENABLED: '1' + CGO_CFLAGS: -O2 -g -fno-strict-aliasing + CGO_CXXFLAGS: -O2 -g -fno-strict-aliasing + GOTOOLCHAIN: local + +jobs: + smoke: + # This does not provision a runner or enable a repository variable. A + # missing/unreviewed host/config is unavailable, never a hosted substitute. + if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.lane == 'smoke' && vars.EDIT_GATE_REFERENCE_SMOKE_ENABLED == 'true' + runs-on: [self-hosted, perf-reference-linux] + # Three separate <=15m smoke runs; each run retains its own wall receipt. + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build the exact candidate and gate commands + env: + EDIT_GATE_SEED: ${{ inputs.seed }} + shell: bash + run: | + set -euo pipefail + tags=() + case "$EDIT_GATE_SEED" in + none) ;; + ack-wait|closed-read-gate) tags=(-tags=gortex_edit_gate_seed) ;; + *) exit 2 ;; + esac + go build -buildvcs=true "${tags[@]}" -o "$RUNNER_TEMP/edit-candidate" ./cmd/gortex + go build -buildvcs=true -o "$RUNNER_TEMP/edit-load" ./bench/load + go build -buildvcs=true -o "$RUNNER_TEMP/edit-check-budgets" ./bench/check-budgets + - name: Collect three smoke runs and enforce every blocking row + env: + EDIT_GATE_SEED: ${{ inputs.seed }} + EDIT_GATE_REFERENCE_SMOKE_CONFIG: ${{ vars.EDIT_GATE_REFERENCE_SMOKE_CONFIG }} + EDIT_GATE_ACK_SEED_CONFIG: ${{ vars.EDIT_GATE_ACK_SEED_CONFIG }} + EDIT_GATE_CLOSED_SEED_CONFIG: ${{ vars.EDIT_GATE_CLOSED_SEED_CONFIG }} + EXPECTED_CANDIDATE: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + case "$EDIT_GATE_SEED" in + none) config="$EDIT_GATE_REFERENCE_SMOKE_CONFIG" ;; + ack-wait) config="$EDIT_GATE_ACK_SEED_CONFIG" ;; + closed-read-gate) config="$EDIT_GATE_CLOSED_SEED_CONFIG" ;; + *) exit 2 ;; + esac + # Keep the reviewed config in place: its corpus/build paths are + # config-relative. Verify the requested fault matches actual tasks. + python3 - "$config" "$EDIT_GATE_SEED" <<'PY' + import json, sys + with open(sys.argv[1], encoding="utf-8") as source: + config = json.load(source) + expected = "" if sys.argv[2] == "none" else sys.argv[2] + if config.get("edit_gate_seed", "") != expected: + raise SystemExit("reviewed config disagrees with the requested fault") + PY + "$RUNNER_TEMP/edit-check-budgets" edit-collect \ + --config "$config" --require-lane smoke \ + --expected-candidate "$EXPECTED_CANDIDATE" \ + --candidate-binary "$RUNNER_TEMP/edit-candidate" \ + --load-binary "$RUNNER_TEMP/edit-load" \ + --out "$RUNNER_TEMP/edit-evidence-$GITHUB_RUN_ID" + - name: Preserve raw JSON, failed rows and owned stop receipts + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: edit-smoke-${{ inputs.seed }}-${{ github.run_id }} + path: ${{ runner.temp }}/edit-evidence-${{ github.run_id }}/ + retention-days: 90 + if-no-files-found: error + + nightly: + if: github.ref == 'refs/heads/main' && vars.EDIT_GATE_REFERENCE_NIGHTLY_ENABLED == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.lane == 'nightly')) + runs-on: [self-hosted, perf-reference-linux] + timeout-minutes: 1440 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build the normal production candidate and gate commands + env: + EDIT_GATE_SEED: ${{ inputs.seed || 'none' }} + shell: bash + run: | + set -euo pipefail + test "$EDIT_GATE_SEED" = none + go build -buildvcs=true -o "$RUNNER_TEMP/edit-candidate" ./cmd/gortex + go build -buildvcs=true -o "$RUNNER_TEMP/edit-load" ./bench/load + go build -buildvcs=true -o "$RUNNER_TEMP/edit-check-budgets" ./bench/check-budgets + - name: Collect the full edit matrix and enforce every blocking row + env: + EDIT_GATE_REFERENCE_NIGHTLY_CONFIG: ${{ vars.EDIT_GATE_REFERENCE_NIGHTLY_CONFIG }} + EXPECTED_CANDIDATE: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + "$RUNNER_TEMP/edit-check-budgets" edit-collect \ + --config "$EDIT_GATE_REFERENCE_NIGHTLY_CONFIG" --require-lane nightly \ + --expected-candidate "$EXPECTED_CANDIDATE" \ + --candidate-binary "$RUNNER_TEMP/edit-candidate" \ + --load-binary "$RUNNER_TEMP/edit-load" \ + --out "$RUNNER_TEMP/edit-evidence-$GITHUB_RUN_ID" + - name: Preserve every matrix report, failure and owned stop receipt + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: edit-nightly-${{ github.run_id }} + path: ${{ runner.temp }}/edit-evidence-${{ github.run_id }}/ + retention-days: 90 + if-no-files-found: error diff --git a/bench/check-budgets/edit_loop_workflow_test.go b/bench/check-budgets/edit_loop_workflow_test.go new file mode 100644 index 000000000..be48a6c9b --- /dev/null +++ b/bench/check-budgets/edit_loop_workflow_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestEditLoopWorkflowHasRealBlockingReferenceLanesAndRetainedEvidence(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/edit-loop-gate.yml") + if err != nil { + t.Fatalf("edit gate has no executable workflow: %v", err) + } + var workflow struct { + Permissions map[string]string `yaml:"permissions"` + Concurrency struct { + Cancel bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Env map[string]string `yaml:"env"` + Jobs map[string]struct { + If string `yaml:"if"` + RunsOn []string `yaml:"runs-on"` + Continue bool `yaml:"continue-on-error"` + Steps []struct { + If string `yaml:"if"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + if workflow.Permissions["contents"] != "read" || len(workflow.Permissions) != 1 || workflow.Concurrency.Cancel || workflow.Env["CGO_ENABLED"] != "1" { + t.Fatal("workflow changed read-only/CGO/owned cleanup contract") + } + for _, lane := range []string{"smoke", "nightly"} { + job, ok := workflow.Jobs[lane] + if !ok || job.Continue || !strings.Contains(job.If, "refs/heads/main") || !strings.Contains(job.If, "ENABLED") || strings.Join(job.RunsOn, ",") != "self-hosted,perf-reference-linux" { + t.Fatalf("%s lane missing explicit trusted reference gate: %+v", lane, job) + } + collects, artifacts := 0, 0 + for _, step := range job.Steps { + if strings.Contains(step.Run, "edit-collect") { + collects++ + for _, flag := range []string{"--expected-candidate", "--candidate-binary", "--load-binary", "--require-lane " + lane} { + if !strings.Contains(step.Run, flag) { + t.Errorf("%s collector lost %s", lane, flag) + } + } + } + if strings.Contains(step.Uses, "actions/upload-artifact@") && step.If == "always()" { + artifacts++ + } + } + if collects != 1 || artifacts != 1 { + t.Fatalf("%s has no actual strict collection/retained failure artifact", lane) + } + } + for _, proof := range []string{"ack-wait", "closed-read-gate", "gortex_edit_gate_seed", "edit_gate_seed", "EDIT_GATE_REFERENCE_SMOKE_CONFIG", "EDIT_GATE_REFERENCE_NIGHTLY_CONFIG"} { + if !strings.Contains(string(data), proof) { + t.Errorf("workflow has no actual %s contract", proof) + } + } +} From baed6830aee196c434981c91952685afcce05baf Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:01:00 +0100 Subject: [PATCH 090/138] fix(freshness): witness unchanged AUTO reader owners and relation tiers --- internal/indexer/mutation_tiers.go | 12 +++ .../mcp/freshness_explicit_contract_test.go | 92 +++++++++++++++- internal/mcp/freshness_rider.go | 101 +++++++++++++++--- 3 files changed, 190 insertions(+), 15 deletions(-) diff --git a/internal/indexer/mutation_tiers.go b/internal/indexer/mutation_tiers.go index 7baa41737..508f379ed 100644 --- a/internal/indexer/mutation_tiers.go +++ b/internal/indexer/mutation_tiers.go @@ -196,6 +196,18 @@ func (mw *MultiWatcher) TierPaths() map[string]MutationTierState { return states } +// TierAuthority reports whether this canonical repository has a live, healthy +// watcher. An empty outstanding-path set alone cannot establish that fact. +func (mw *MultiWatcher) TierAuthority(prefix string) bool { + if mw == nil { + return false + } + mw.mu.Lock() + defer mw.mu.Unlock() + w := mw.watchers[prefix] + return !mw.stopped && mw.started[prefix] && w != nil && w.DegradedReason() == "" +} + func (mw *MultiWatcher) TierState(path string) MutationTierState { mw.mu.Lock() _, prefix := mw.multi.IndexerForFile(path) diff --git a/internal/mcp/freshness_explicit_contract_test.go b/internal/mcp/freshness_explicit_contract_test.go index 33ff3d028..b2a3bd9da 100644 --- a/internal/mcp/freshness_explicit_contract_test.go +++ b/internal/mcp/freshness_explicit_contract_test.go @@ -10,13 +10,21 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" ) +type explicitTierWatcher struct { + watchedTierFixture + enabled bool +} + +func (w *explicitTierWatcher) TierAuthority(prefix string) bool { return w.enabled && prefix == "repo" } + func explicitFreshnessFixture(t *testing.T) (*Server, context.Context, func()) { t.Helper() - stack := newViewStack(t) + stack := newViewStackWithRepos(t, false) require.NoError(t, stack.leases.RegisterRepositoryOwner(viewTestOwner())) authority := indexer.NewOutputGenerationAuthority(stack.leases) mutate := func() { @@ -29,7 +37,8 @@ func explicitFreshnessFixture(t *testing.T) (*Server, context.Context, func()) { require.NoError(t, receipt.Complete()) } mutate() - stack.srv.SetWatcher(&watchedTierFixture{}) + stack.srv.multiIndexer.CompleteStartupRelations() + stack.srv.SetWatcher(&explicitTierWatcher{enabled: true}) ctx := withRequestView(t.Context(), &requestView{reader: newBaseGraphReader(stack.store, "repo"), baseNarrowed: true}) req := mcp.CallToolRequest{} req.Params.Arguments = map[string]any{"require_fresh": true} @@ -92,6 +101,85 @@ func TestRequireFreshRejectsCompletedMutationDuringRead(t *testing.T) { require.Equal(t, "unknown", resultJSON(t, after)["freshness"].(map[string]any)["graph_freshness"]) } +func TestRequireFreshUsesActualAutoCanonicalReader(t *testing.T) { + s, _, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + got, err := s.wrapToolHandler(s.handleGetCallers)(t.Context(), req) + require.NoError(t, err) + require.False(t, got.IsError, resultText(got)) + require.Equal(t, "fresh", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) + require.NotContains(t, req.GetArguments(), "view", "AUTO must not be replaced by a BASE control") +} + +func TestRequireFreshRejectsUntrackedCanonicalWatcher(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + s.SetWatcher(&explicitTierWatcher{enabled: false}) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestRequireFreshAutoOwnerAddedDuringReadIsUnknown(t *testing.T) { + s, _, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "get_callers" + req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "format": "json"} + ctx, release := s.captureExplicitFreshness(t.Context(), req) + require.NotNil(t, release) + defer release() + before := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "fresh", resultJSON(t, before)["freshness"].(map[string]any)["graph_freshness"]) + _, err := s.multiIndexer.TrackRepoCtx(t.Context(), config.RepoEntry{Name: "added", Path: setupMiniRepo(t, "added")}) + require.NoError(t, err) + after := s.decorateToolFreshness(ctx, req.Params.Name, req, mcp.NewToolResultText(`{"callers":[]}`)) + require.Equal(t, "unknown", resultJSON(t, after)["freshness"].(map[string]any)["graph_freshness"]) +} + +func TestRequireFreshRelationPayloadDoesNotUseFileTierAlone(t *testing.T) { + for _, tool := range []string{"get_file_summary", "get_editing_context"} { + t.Run(tool, func(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + watcher := s.currentWatcher().(*explicitTierWatcher) + watcher.paths = map[string]indexer.MutationTierState{ + s.multiIndexer.ResolveFilePath("repo/target.go"): {Generation: 1, AckAt: time.Now(), FileGraphAt: time.Now()}, + } + req := mcp.CallToolRequest{} + req.Params.Name = tool + req.Params.Arguments = map[string]any{"path": "repo/target.go", "require_fresh": true, "format": "json"} + got := s.decorateToolFreshness(ctx, tool, req, mcp.NewToolResultText(`{"callers":["old"]}`)) + fields := resultJSON(t, got)["freshness"].(map[string]any) + require.Equal(t, "pending", fields["graph_freshness"]) + require.Equal(t, "relations", fields["freshness_scope"]) + }) + } +} + +func TestRequireFreshFacadeForwardsExplicitTierRequest(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + s, ctx, _ := explicitFreshnessFixture(t) + req := mcp.CallToolRequest{} + req.Params.Name = "relations" + req.Params.Arguments = map[string]any{ + "operation": "callers", "target": map[string]any{"symbol": "repo/target.go::Target"}, + "options": map[string]any{"require_fresh": true}, "output": map[string]any{"format": format}, + } + got, err := s.handleFacade(ctx, "relations", req) + require.NoError(t, err) + require.False(t, got.IsError, resultText(got)) + if format == "json" { + require.Equal(t, "fresh", resultJSON(t, got)["freshness"].(map[string]any)["graph_freshness"]) + } else { + require.True(t, strings.HasPrefix(resultText(got), "relations: fresh (0 files)\n"), resultText(got)) + } + }) + } +} + func TestRequireFreshDoesNotInventGraphAuthority(t *testing.T) { for _, route := range []string{"unwatched", "checkout", "source", "error"} { t.Run(route, func(t *testing.T) { diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 8e162e56c..509d459a2 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -700,6 +700,9 @@ func (s *Server) captureToolFreshness(ctx context.Context) context.Context { func freshnessTargets(name string, req mcp.CallToolRequest) (scope string, ids, files []string) { if file := targetRepoFileRaw(name, req); file != "" { + if (name == "get_file_summary" || name == "get_editing_context") && requestBoolArg(&req, requireFreshArgName) { + return "relations", nil, []string{file} + } return "file", nil, []string{file} } switch name { @@ -793,32 +796,104 @@ func (s *Server) explicitFreshness(ctx context.Context, req mcp.CallToolRequest, // a different claim and cannot provide this tier authority. if _, watched := s.canonicalTierPaths(ctx); watched { label.state = "unknown" - if pin, ok := ctx.Value(explicitFreshnessPinKey{}).(*graphview.BasePin); ok && pin.ValidateAcceptedCurrent() == nil { - label.state = "fresh" + witness, ok := ctx.Value(explicitFreshnessPinKey{}).(*explicitFreshnessWitness) + if !ok || witness.watcher != s.currentWatcher() || !slices.Equal(witness.prefixes, s.canonicalFreshnessPrefixes(ctx)) { + return label + } + authority, tracked := s.currentWatcher().(interface{ TierAuthority(string) bool }) + if !tracked { + return label + } + inScope := func(file string) bool { + return slices.ContainsFunc(witness.prefixes, func(prefix string) bool { return strings.HasPrefix(file, prefix+"/") }) + } + _, ids, files := freshnessTargets(req.Params.Name, req) + for _, id := range ids { + file := graph.IDFile(id) + if file == "" || !inScope(file) { + return label + } + } + for _, file := range files { + if !inScope(s.graphRelPath(ctx, file)) { + return label + } + } + for i, pin := range witness.pins { + root, known := s.multiIndexer.RepoRoot(witness.prefixes[i]) + if !known || root != witness.roots[i] || s.multiIndexer.GetIndexer(witness.prefixes[i]) != witness.owners[i] || !authority.TierAuthority(witness.prefixes[i]) || pin.ValidateAcceptedCurrent() != nil { + return label + } } + label.state = "fresh" } return label } type explicitFreshnessPinKey struct{} -// The accepted source witness belongs to the canonical repository that this -// request's reader actually filters. It is captured before the read, released -// with the request, and checked again when that read's tier label is rendered. +type explicitFreshnessWitness struct { + watcher watcherHistory + prefixes []string + pins []*graphview.BasePin + owners []*indexer.Indexer + roots []string +} + +func (s *Server) canonicalFreshnessPrefixes(ctx context.Context) []string { + view := requestViewFromContext(ctx) + if OverlayViewFromContext(ctx) != nil || view != nil && (view.readsOwnCheckout() || view.rider != nil && !view.routeless && !view.rider.Exact) { + return nil + } + if prefix := baseNarrowedRepoPrefix(view); prefix != "" { + return []string{prefix} + } + if view != nil && view.reader != nil || s.multiIndexer == nil { + return nil + } + // AUTO can serve the unnarrowed shared corpus. Witness every canonical + // owner that reader may consult; do not substitute a BASE selector or a + // narrower query merely to obtain a positive freshness observation. + prefixes := s.multiIndexer.RepoPrefixes() + slices.Sort(prefixes) + return prefixes +} + +// Capture accepted source witnesses for the actual canonical reader before +// the read. Projection, editor layers and other checkout/ref readers retain +// their existing scope and cannot borrow these canonical publication facts. func (s *Server) captureExplicitFreshness(ctx context.Context, req mcp.CallToolRequest) (context.Context, func()) { - if !requestBoolArg(&req, requireFreshArgName) || isCompact(req) || s.materializer == nil || s.materializer.Leases == nil { + if !requestBoolArg(&req, requireFreshArgName) || isCompact(req) || s.materializer == nil || s.materializer.Leases == nil || s.multiIndexer == nil { return ctx, nil } - prefix := baseNarrowedRepoPrefix(requestViewFromContext(ctx)) - if prefix == "" { + prefixes := s.canonicalFreshnessPrefixes(ctx) + authority, tracked := s.currentWatcher().(interface{ TierAuthority(string) bool }) + if len(prefixes) == 0 || !tracked { return ctx, nil } - pin := s.materializer.Leases.AcquireBaseCorpus(prefix) - if pin.ValidateAcceptedCurrent() != nil { - pin.Release() - return ctx, nil + witness := &explicitFreshnessWitness{watcher: s.currentWatcher(), prefixes: prefixes} + release := func() { + for _, pin := range witness.pins { + pin.Release() + } + } + for _, prefix := range prefixes { + owner := s.multiIndexer.GetIndexer(prefix) + root, known := s.multiIndexer.RepoRoot(prefix) + if owner == nil || !known || root == "" || !authority.TierAuthority(prefix) { + release() + return ctx, nil + } + pin := s.materializer.Leases.AcquireBaseCorpus(prefix) + witness.pins = append(witness.pins, pin) + witness.owners = append(witness.owners, owner) + witness.roots = append(witness.roots, root) + if pin.ValidateAcceptedCurrent() != nil { + release() + return ctx, nil + } } - return context.WithValue(ctx, explicitFreshnessPinKey{}, pin), pin.Release + return context.WithValue(ctx, explicitFreshnessPinKey{}, witness), release } type toolFreshnessBudgetKey struct{} From 9fe542b1057c17ae2d69e6e29ea37872adb078f9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:02:46 +0100 Subject: [PATCH 091/138] test(freshness): reject unstarted and stopped canonical watchers --- internal/indexer/multi_watcher_visibility_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/indexer/multi_watcher_visibility_test.go b/internal/indexer/multi_watcher_visibility_test.go index bb8403d42..ee61feb6d 100644 --- a/internal/indexer/multi_watcher_visibility_test.go +++ b/internal/indexer/multi_watcher_visibility_test.go @@ -14,6 +14,7 @@ import ( // every configured repo as watched and the graph aged behind a green status. func TestMultiWatcherReportsRepoThatNeverStarted(t *testing.T) { mw, _, repoADir, _ := setupMultiWatcherTest(t) + require.False(t, mw.TierAuthority("repo-a"), "configured is not yet watching") // Repo A's root disappears before the watchers come up — a checkout // removed under a running daemon. @@ -24,6 +25,9 @@ func TestMultiWatcherReportsRepoThatNeverStarted(t *testing.T) { live, configured := mw.WatchedRepos() require.Equal(t, 2, configured) require.Equal(t, 1, live, "only repo-b can be watched once repo-a's root is gone") + require.False(t, mw.TierAuthority("repo-a"), "a failed startup cannot certify an empty tier set") + require.True(t, mw.TierAuthority("repo-b")) + require.False(t, mw.TierAuthority("unknown-repo")) reason := mw.DegradedReason() require.NotEmpty(t, reason, "an unwatched repository must not read as healthy") @@ -37,10 +41,15 @@ func TestMultiWatcherHealthyWhenAllReposStart(t *testing.T) { mw, _, _, _ := setupMultiWatcherTest(t) require.NoError(t, mw.Start()) - t.Cleanup(func() { _ = mw.Stop() }) + t.Cleanup(func() { + _ = mw.Stop() + require.False(t, mw.TierAuthority("repo-a"), "a stopped watcher cannot certify a tier") + }) live, configured := mw.WatchedRepos() require.Equal(t, configured, live) require.Equal(t, 2, live) require.Empty(t, mw.DegradedReason()) + require.True(t, mw.TierAuthority("repo-a")) + require.True(t, mw.TierAuthority("repo-b")) } From ab80a3e346cc8e9c26f86d36930b5befaa8f73cc Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:03:09 +0100 Subject: [PATCH 092/138] feat: request scoped freshness on gate graph probes --- bench/check-budgets/EDIT-LOOP.md | 19 +++++++ bench/load/edit_gate_fresh_request_test.go | 66 ++++++++++++++++++++++ bench/load/edit_probe_run.go | 4 ++ 3 files changed, 89 insertions(+) create mode 100644 bench/check-budgets/EDIT-LOOP.md create mode 100644 bench/load/edit_gate_fresh_request_test.go diff --git a/bench/check-budgets/EDIT-LOOP.md b/bench/check-budgets/EDIT-LOOP.md new file mode 100644 index 000000000..98a6a82b0 --- /dev/null +++ b/bench/check-budgets/EDIT-LOOP.md @@ -0,0 +1,19 @@ +# Edit-loop gates + +`check-budgets edit-plan` and `edit-collect` select the edit-loop gate without changing the existing performance lanes or ordinary load plans. `edit-loop` evaluates retained reports. Unavailable evidence stays explicit and blocking; a successful plan is not a measurement. + +A smoke cell has at least 100 **total** logical edits. The opt-in `--edit-gate-modes` rotates JSON, TOON, compact text, facade-v1 and CLI probes over those edits, with explicit per-record mode and actual counts. At 100 edits the offered population is 20 per mode. The four-editor Poisson schedule, 15-second per-editor mean and JSON edit acknowledgements are retained. Coverage requires actual assigned edits, decoded answers and applicable tool witnesses, not just a planned count. + +Immediate reads use the same editor session after the ACK, with no sleep or barrier. The existing file oracle is reused alongside symbol source, file summary, symbol search and text search. Edit plus one-read latency ends at the first symbol-source read response; later readbacks do not extend that interval. Whole-run monotonic wall evidence includes owned preparation, readiness and normal cleanup. Each smoke run has a 15-minute bound; three independent runs of the same build must agree on every blocking row. Nightly runs retain their original offered schedule and cover 1/4/8/16/32 editors, six independent pinned repositories and the admitted large-file row. + +Six-repository edit plus one-read p95 is reported as `OBSERVATIONAL`, with a null budget and an explicit unbudgeted reason. No dedicated p95 target is specified for this cell. Its ACK target, universal readback maximum and other blocking rows remain enforced. Missing or duplicate observations remain unavailable. + +Compact freshness is the sole qualified unobservable number: its value stays null with a reason, never zero or PASS. It is nonblocking only after actual semantic witnesses for `read.summary`, `search.symbols`, `relations.callers` and `trace.call_chain`, assigned-edit counts and decoding checks pass. Compact `read.source`, `read.editing_context` and `search.text` are unsupported by the producer contract; each requires an explicit format-refusal record and actual decoded JSON fallback proof, with no compact coverage credit. Missing supported operations, missing fallback/refusal proof, stale content or decode failures remain blocking. The other four mode rows require explicit answer-local freshness observations. + +The reference workflow is `.github/workflows/edit-loop-gate.yml`. It runs trusted main code on a separately registered `perf-reference-linux` owned runner. It never substitutes a hosted runner for the reference Linux cgroup/8-CPU/16-GB contract. It does not register a runner or enable scheduling. Operators supply reviewed machine-bound configurations through `EDIT_GATE_REFERENCE_SMOKE_CONFIG` and `EDIT_GATE_REFERENCE_NIGHTLY_CONFIG` and explicitly enable the corresponding `EDIT_GATE_REFERENCE_SMOKE_ENABLED` / `EDIT_GATE_REFERENCE_NIGHTLY_ENABLED` variables. Configurations use the existing strict lane schema, reviewed pinned corpus manifests and exact clean candidate/main/release revisions. The nightly large-file manifest must prove the actual target has at least 10,000 admitted lines. Raw per-run JSON, budget tables, failures and owned shutdown receipts are retained as workflow artifacts. + +Two manual smoke faults are available: `ack-wait` and `closed-read-gate`. Their separately reviewed configurations are selected through `EDIT_GATE_ACK_SEED_CONFIG` and `EDIT_GATE_CLOSED_SEED_CONFIG`; `edit_gate_seed` must match the requested fault. The workflow builds the candidate with `gortex_edit_gate_seed` only for those fault runs. The load harness verifies the actual executable's build tag before allocating private state and forwards the known seed explicitly to its owned daemon. Ordinary builds compile the hooks out and ordinary private environments strip ambient seed variables. Faults cannot be selected through the ordinary collector, a nightly lane or a paired run. + +The ACK seed restores a real three-second wait on the admitted ACK path inside its existing accounting phase. The closed-gate seed takes the real urgent reclaim admission path and records actual pause/hold observations. Neither edits JSON results nor synthesizes counters. A seeded run must actually fail its intended comparator row; local switch tests alone do not satisfy the required failing-workflow evidence links. The workflow retains failed artifacts and does not tolerate a blocking comparator failure. + +No reference measurement, three-run stability result or seeded failing-workflow link is implied by installing this source. Those remain explicit acceptance evidence from an admitted run. diff --git a/bench/load/edit_gate_fresh_request_test.go b/bench/load/edit_gate_fresh_request_test.go new file mode 100644 index 000000000..ea395dcb7 --- /dev/null +++ b/bench/load/edit_gate_fresh_request_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestEditGateRequestsAnswerFreshnessWithoutChangingAutoViewOrOrdinaryReads(t *testing.T) { + for _, cell := range []struct { + mode string + gate, want bool + }{{"json", true, true}, {"toon", true, true}, {"facade", true, true}, {"compact", true, false}, {"json", false, false}} { + t.Run(cell.mode+map[bool]string{true: "-gate", false: "-ordinary"}[cell.gate], func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + c := editProbeCorpus() + plan, err := makeEditProbePlan(c, 42, 1) + if err != nil { + t.Fatal(err) + } + plan.GateModes = cell.gate + plan.Steps[0].ProbeMode = cell.mode + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.File), []byte("package fixture\n"+c.EditAnchor+"\n"), 0600); err != nil { + t.Fatal(err) + } + observed := make(chan map[string]any, 64) + run := newEditProbeStubRun(t, ctx, c, plan, dir, 1, func(frame stubFrame) (any, *rpcError) { + if frame.Method == "tools/call" { + args, _ := frame.Params["arguments"].(map[string]any) + row := map[string]any{"tool": frame.Params["name"], "operation": args["operation"], "require_fresh": args["require_fresh"], "view": args["view"]} + select { + case observed <- row: + default: + } + } + return stubContent(map[string]any{}), nil + }) + state := run.states[0] + defer state.finishRaw() + run.probe(ctx, cancel, state) + seen := map[string]bool{} + for len(seen) < 7 { + select { + case row := <-observed: + tool, _ := row["tool"].(string) + operation, _ := row["operation"].(string) + seen[tool+"."+operation] = true + if (row["require_fresh"] == true) != cell.want { + t.Errorf("actual %s.%s freshness request=%v, want%t", tool, operation, row["require_fresh"], cell.want) + } + if row["view"] != nil { + t.Errorf("gate silently changed AUTO scope to%v", row["view"]) + } + case <-ctx.Done(): + t.Fatalf("did not observe all seven actual graph probe operations: %v", seen) + } + } + cancel() + state.probes.Wait() + }) + } +} diff --git a/bench/load/edit_probe_run.go b/bench/load/edit_probe_run.go index 3373756b9..3d0af8fbe 100644 --- a/bench/load/edit_probe_run.go +++ b/bench/load/edit_probe_run.go @@ -692,6 +692,10 @@ func (r *editProbeRuntime) probe(probeCtx context.Context, cancel context.Cancel corpus := repo.Corpus corpus.EditOutput = r.stepProbeMode(s.step) for index, oracle := range editProbeOracles(corpus, s.step) { + if r.plan.GateModes && r.stepProbeMode(s.step) != "compact" { + oracle.call.Arguments = copyJSON(oracle.call.Arguments) + oracle.call.Arguments["require_fresh"] = true + } s.probes.Go(func() { ticker := time.NewTicker(editProbePeriod) defer ticker.Stop() From 9c079c69e69764c05d2805547f663b0ac84739f7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:06:02 +0100 Subject: [PATCH 093/138] fix: retain explicit scoped fresh metadata in gate decoding --- bench/load/edit_gate_fresh_prefix_test.go | 42 +++++++++++++++++++++++ bench/load/edit_gate_freshness.go | 5 ++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 bench/load/edit_gate_fresh_prefix_test.go diff --git a/bench/load/edit_gate_fresh_prefix_test.go b/bench/load/edit_gate_fresh_prefix_test.go new file mode 100644 index 000000000..3b7d9ae00 --- /dev/null +++ b/bench/load/edit_gate_fresh_prefix_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "errors" + "testing" + + toon "github.com/toon-format/toon-go" +) + +func TestEditGateDecodesExplicitFreshScopeWithoutInventingMissingClaims(t *testing.T) { + encoded, err := toon.Marshal(map[string]any{"nodes": []any{map[string]any{"id": "fixture/main.go::Added"}}, "total": 1, "truncated": false}) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{"file", "relations", "derived"} { + body, err := decodeEditProbeOutput(outputWire(scope+": fresh (0 files)\n"+string(encoded), nil), "toon", "read.summary") + if err != nil { + t.Fatalf("actual explicit fresh prefix cannot be decoded: %v", err) + } + if body["graph_freshness"] != "fresh" || body["freshness_scope"] != scope || body["pending_file_count"] != json.Number("0") || !editProbeClaimsFresh(body) { + t.Fatalf("actual scoped claim discarded: %+v", body) + } + } + for _, text := range []string{"relations: fresh (2 files)\n" + string(encoded), "relations: fresh (0 files)\nfile: fresh (0 files)\n" + string(encoded)} { + if _, err := decodeEditProbeOutput(outputWire(text, nil), "toon", "read.summary"); err == nil { + t.Fatal("contradictory/repeated fresh prefix accepted") + } + } + body, err := decodeEditProbeOutput(outputWire(string(encoded), map[string]any{"graph_freshness": "fresh"}), "toon", "read.summary") + if err != nil { + t.Fatal(err) + } + if editProbeClaimsFresh(body) { + t.Fatal("missing actual fresh claim was borrowed from structured sibling") + } + gcx := "GCX1 tool=get_file_summary fields=id graph_freshness=fresh freshness_scope=relations pending_file_count=0\n# relations: fresh (0 files)\nfixture/main.go::Added\n" + body, err = decodeEditProbeOutput(outputWire(gcx, nil), "gcx", "read.summary") + if !errors.Is(err, errEditOutputGCXUnavailable) || body["graph_freshness"] != "fresh" { + t.Fatalf("GCX header lost actual metadata or acquired row decoding: %v %v", body, err) + } +} diff --git a/bench/load/edit_gate_freshness.go b/bench/load/edit_gate_freshness.go index 3be07407b..2dc88e9e6 100644 --- a/bench/load/edit_gate_freshness.go +++ b/bench/load/edit_gate_freshness.go @@ -13,7 +13,7 @@ import ( wire "github.com/gortexhq/gcx-go" ) -var editProbeTierLine = regexp.MustCompile(`^(file|relations|derived): (pending|pending_unknown|unknown|failed) \(([0-9]+) files\)$`) +var editProbeTierLine = regexp.MustCompile(`^(file|relations|derived): (pending|pending_unknown|unknown|failed|fresh) \(([0-9]+) files\)$`) // The label is producer evidence, not disposable prose. Keep its exact scope, // state and count while decoding the underlying compact/TOON payload normally. @@ -30,6 +30,9 @@ func editProbeTierPrefix(text []byte) ([]byte, map[string]any, error) { if err != nil { return nil, nil, err } + if string(match[2]) == "fresh" && count != 0 { + return nil, nil, errors.New("freshness label claims fresh with pending files") + } next, _, _ := bytes.Cut(rest, []byte("\n")) if editProbeTierLine.Match(next) { return nil, nil, errors.New("repeated freshness label") From 7191ce2f1e36d2cc5ec6d5ac311f630018283e3f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:07:32 +0100 Subject: [PATCH 094/138] style(freshness): align explicit source witness fields --- internal/mcp/freshness_rider.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 509d459a2..146cece39 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -833,11 +833,11 @@ func (s *Server) explicitFreshness(ctx context.Context, req mcp.CallToolRequest, type explicitFreshnessPinKey struct{} type explicitFreshnessWitness struct { - watcher watcherHistory + watcher watcherHistory prefixes []string - pins []*graphview.BasePin - owners []*indexer.Indexer - roots []string + pins []*graphview.BasePin + owners []*indexer.Indexer + roots []string } func (s *Server) canonicalFreshnessPrefixes(ctx context.Context) []string { From 384ce52b3bf8ccf42924d49be1700d50de252a98 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:13:23 +0100 Subject: [PATCH 095/138] docs(freshness): distinguish explicit graph claims from route freshness --- internal/mcp/guide.go | 2 ++ internal/mcp/relation_freshness.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mcp/guide.go b/internal/mcp/guide.go index 580363626..8d7910653 100644 --- a/internal/mcp/guide.go +++ b/internal/mcp/guide.go @@ -129,6 +129,8 @@ The session CWD selects an automatically discovered checkout overlay. Do not cal A readback_exact:true acknowledgement means continue without polling. Check graph_freshness before acting on callers, usages, impact, or a rename plan. Before rename, delete, or signature changes, use require_fresh:true with freshness_scope:"relations" and wait_deadline. A symbol whose file is pending must not be treated as dead. Watched primary checkouts wait for the requested file, relations, or derived tier; immutable ref/commit views cannot advance. +With require_fresh, noncompact canonical graph reads report graph_freshness:"fresh" only when the selected tier's accepted sources remain current throughout the read. Unknown means that proof is unavailable; route-level fresh is a separate claim. Compact output has no explicit positive graph-freshness contract. + Every response carries freshness metadata. ` + "`exact:false`" + ` means the requested view was not served, and every fallback is read-only. Set ` + "`require_exact:true`" + ` to reject fallback, ` + "`require_fresh:true`" + ` to wait for current filesystem state, and bound waiting with an absolute RFC3339 ` + "`wait_deadline`" + ` (a malformed or past deadline is refused). ` + "`require_fresh`" + ` on graph-dependent operations waits for the selected checkout's route to reach the working copy as the coordinator samples it now, and reports what the wait did: ` + "`fresh`" + `, ` + "`waited_ms`" + `, ` + "`wait_deadline`" + `, and ` + "`fresh_reason`" + ` when it is false. ` + "`fresh:true`" + ` is reported only when the answer is read through the very route the wait published — the same checkout, served exactly, at the same route epoch; anything else answers ` + "`fresh:false`" + `. The ` + "`fresh_reason`" + ` vocabulary is ` + "`deadline_exceeded`" + ` (your bound expired with the route still behind the working copy), ` + "`interrupted`" + ` (the request ended first), ` + "`refresh_admission_abandoned`" + ` (the coordinator gave up on a bound of its own before admitting any refresh, so a longer wait_deadline is not the fix), ` + "`coordinator_unavailable`" + ` (this server can publish no checkout generation at all), ` + "`checkout_root_changed`" + ` and ` + "`checkout_refresh_stopped`" + ` (this one checkout moved, or its coordinator stopped), ` + "`publication_failed`" + `, ` + "`route_withdrawn`" + ` (publication succeeded and something else answered: a base fallback, another checkout, or a route that moved again), ` + "`committed_base_advance_unimplemented`" + `, and ` + "`wait_target_unavailable`" + ` (the route could not be resolved to wait on, or read back afterwards — a retry may succeed). diff --git a/internal/mcp/relation_freshness.go b/internal/mcp/relation_freshness.go index b5ae5ac8d..e97072bc0 100644 --- a/internal/mcp/relation_freshness.go +++ b/internal/mcp/relation_freshness.go @@ -28,7 +28,7 @@ const maxLabelledPendingFiles = 16 // its scope are still being published. The zero value is a fresh scope. type relationFreshness struct { scope string - state string // "", "pending" or "failed" + 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 } From e203d6fc5f22fc51100bb592c5eee538d231f58e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:17:28 +0100 Subject: [PATCH 096/138] style(freshness): use tagged format switches in contract tests --- internal/mcp/freshness_explicit_contract_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/mcp/freshness_explicit_contract_test.go b/internal/mcp/freshness_explicit_contract_test.go index b2a3bd9da..5ec13dcd6 100644 --- a/internal/mcp/freshness_explicit_contract_test.go +++ b/internal/mcp/freshness_explicit_contract_test.go @@ -57,11 +57,12 @@ func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { req.Params.Name = "get_callers" req.Params.Arguments = map[string]any{"id": "repo/target.go::Target", "require_fresh": true, "freshness_scope": scope, "format": format} body := "callers[0]{id}:" - if format == "json" { + switch format { + case "json": body = `{"callers":[],"complete":false}` - } else if format == "gcx" { + case "gcx": body = "GCX1 tool=get_callers\nrow1" - } else if format == "compact" { + case "compact": req.Params.Arguments.(map[string]any)["compact"] = true } original := mcp.NewToolResultText(body) @@ -70,7 +71,8 @@ func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { require.Same(t, original, got, "compact has no new explicit-fresh contract") return } - if format == "json" { + switch format { + case "json": payload := resultJSON(t, got) fields, ok := payload["freshness"].(map[string]any) require.True(t, ok, resultText(got)) @@ -78,10 +80,10 @@ func TestRequireFreshReportsExplicitGraphTier(t *testing.T) { require.Equal(t, scope, fields["freshness_scope"]) require.EqualValues(t, 0, fields["pending_file_count"]) require.Equal(t, false, payload["complete"], "freshness must preserve an answer's completeness") - } else if format == "gcx" { + case "gcx": require.Contains(t, resultText(got), "graph_freshness=fresh") require.Contains(t, resultText(got), "# "+scope+": fresh (0 files)\n") - } else { + default: require.True(t, strings.HasPrefix(resultText(got), scope+": fresh (0 files)\n"), resultText(got)) } }) From af7c9b5c858ae331608ae9a2c3c29536e0b6baf7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 22:21:12 +0100 Subject: [PATCH 097/138] style: keep compact gate qualification explicit --- bench/check-budgets/edit_loop.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bench/check-budgets/edit_loop.go b/bench/check-budgets/edit_loop.go index df992c60c..2c0a6673b 100644 --- a/bench/check-budgets/edit_loop.go +++ b/bench/check-budgets/edit_loop.go @@ -451,7 +451,10 @@ func editLoopRowBlocks(row editLoopRow) bool { if row.Scenario == "multirepoedit-6" && row.Metric == "readback_p95" && row.State == "OBSERVATIONAL" && row.Budget == nil && row.Value != nil && finiteNonnegative(*row.Value) && row.Unit == "s" { return false } - return row.State != "PASS" && !(row.Metric == "silent_stale_compact" && row.State == "UNOBSERVABLE" && row.Value == nil) + if row.Metric == "silent_stale_compact" && row.State == "UNOBSERVABLE" && row.Value == nil { + return false + } + return row.State != "PASS" } func editLoopCompare(value, target float64, compare string) bool { From 1eff0d5fee96e12e835902846d6c9aa5ad8a35e9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sun, 1 Nov 2026 23:18:18 +0100 Subject: [PATCH 098/138] fix: run code checks for stacked pull request bases (cherry picked from commit d642ca27aff1221b45f8233711d919112e0c61c3) --- .github/workflows/ci.yml | 1 - .github/workflows/security.yml | 1 - scripts/ci-result/workflow_events_test.go | 102 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 scripts/ci-result/workflow_events_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6089f7081..739356111 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] types: [opened, synchronize, reopened, labeled, unlabeled] # Run on the merged commit to verify the extended parser regression gate (#991). workflow_dispatch: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 75e829e41..21097dd92 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] schedule: - cron: '0 6 * * *' diff --git a/scripts/ci-result/workflow_events_test.go b/scripts/ci-result/workflow_events_test.go new file mode 100644 index 000000000..f683b4c39 --- /dev/null +++ b/scripts/ci-result/workflow_events_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "maps" + "os" + "path/filepath" + "slices" + "testing" + + "gopkg.in/yaml.v3" +) + +type workflowEventContract struct { + On map[string]yaml.Node `yaml:"on"` + Permissions map[string]string `yaml:"permissions"` +} + +func readWorkflowEvents(t *testing.T, name string) workflowEventContract { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", name)) + if err != nil { + t.Fatal(err) + } + var workflow workflowEventContract + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + return workflow +} + +func TestCodeChecksRunForEveryPullRequestBase(t *testing.T) { + for _, name := range []string{"ci.yml", "security.yml"} { + t.Run(name, func(t *testing.T) { + workflow := readWorkflowEvents(t, name) + event, ok := workflow.On["pull_request"] + if !ok { + t.Fatal("normal pull request checks are absent") + } + var filters map[string]yaml.Node + if err := event.Decode(&filters); err != nil { + t.Fatal(err) + } + for _, key := range []string{"branches", "branches-ignore"} { + if _, filtered := filters[key]; filtered { + t.Errorf("%s restricts PR bases with %s; dependency-base PR heads receive no checks", name, key) + } + } + }) + } +} + +func TestCodeTriggerChangePreservesPushAndMeasurementBoundaries(t *testing.T) { + for _, name := range []string{"ci.yml", "security.yml"} { + workflow := readWorkflowEvents(t, name) + event, ok := workflow.On["push"] + if !ok { + t.Fatal("missing main push checks") + } + var push struct { + Branches []string `yaml:"branches"` + } + if err := event.Decode(&push); err != nil { + t.Fatal(err) + } + if !slices.Equal(push.Branches, []string{"main"}) { + t.Errorf("%s changed push scope: %v", name, push.Branches) + } + if _, trusted := workflow.On["pull_request_target"]; trusted { + t.Fatal("untrusted PR source must not switch to target-context execution") + } + var request struct { + Types []string `yaml:"types"` + } + event = workflow.On["pull_request"] + if err := event.Decode(&request); err != nil { + t.Fatal(err) + } + if name == "ci.yml" && !slices.Equal(request.Types, []string{"opened", "synchronize", "reopened", "labeled", "unlabeled"}) { + t.Fatalf("CI PR activity types changed: %v", request.Types) + } + if name == "security.yml" { + if len(request.Types) != 0 { + t.Fatal("security must retain its default PR activity types") + } + if !maps.Equal(workflow.Permissions, map[string]string{"contents": "read", "security-events": "write"}) { + t.Fatalf("security token permissions changed: %v", workflow.Permissions) + } + } + } + // Performance measurement keeps its separate opt-in main-base contract. + perf := readWorkflowEvents(t, "perf-gate.yml") + var request struct { + Branches []string `yaml:"branches"` + } + event := perf.On["pull_request"] + if err := event.Decode(&request); err != nil { + t.Fatal(err) + } + if !slices.Equal(request.Branches, []string{"main"}) { + t.Fatalf("performance trigger scope changed: %v", request.Branches) + } +} From 10bd4f76d21195337f47eb729851fc3ba2536c78 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 00:49:25 +0100 Subject: [PATCH 099/138] test: normalize owned GCX path cells through actual wire escaping --- internal/mcp/search_gcx_golden_path_test.go | 33 +++++++++++++++++++++ internal/mcp/search_golden_test.go | 25 +++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 internal/mcp/search_gcx_golden_path_test.go diff --git a/internal/mcp/search_gcx_golden_path_test.go b/internal/mcp/search_gcx_golden_path_test.go new file mode 100644 index 000000000..f7b6a61a4 --- /dev/null +++ b/internal/mcp/search_gcx_golden_path_test.go @@ -0,0 +1,33 @@ +package mcp + +import ( + "bytes" + "strings" + "testing" + + wire "github.com/gortexhq/gcx-go" + "github.com/stretchr/testify/require" +) + +func TestSearchGoldenNormalizationUsesActualGCXPathEscaping(t *testing.T) { + for _, root := range []string{`D:\owned\checkout`, `\\host\share\checkout`, "/owned/tab\tand\nnewline"} { + t.Run(root, func(t *testing.T) { + encode := func(path string) string { + var buffer bytes.Buffer + encoder := wire.NewEncoder(&buffer, wire.Header{Tool: "search_symbols", Fields: []string{"id", "path_abs", "sig", "unrelated", "empty"}}) + require.NoError(t, encoder.WriteComment("preserve header, comment and trailing empty cell")) + require.NoError(t, encoder.WriteRow("server.go::Handle", path, "func literal(\"D:\\code\")\tline\nnext", root+"-sibling", "")) + require.NoError(t, encoder.WriteRow("unrelated.go::Other", root+"-sibling/unrelated.go", `func keep("D:\literal")`, "", "")) + require.NoError(t, encoder.Close()) + return buffer.String() + } + separator := "/" + if strings.Contains(root, `\`) { + separator = `\` + } + actual := encode(root + separator + "server" + separator + "server.go") + want := encode("$ROOT/server/server.go") + require.Equal(t, want, normalizeSearchGoldenRoots(actual, []string{root}), "decode only the path cell; unrelated wire bytes must stay exact") + }) + } +} diff --git a/internal/mcp/search_golden_test.go b/internal/mcp/search_golden_test.go index 52f515660..f5c43c544 100644 --- a/internal/mcp/search_golden_test.go +++ b/internal/mcp/search_golden_test.go @@ -1,6 +1,7 @@ package mcp import ( + "bytes" "encoding/json" "flag" "os" @@ -9,6 +10,7 @@ import ( "strings" "testing" + wire "github.com/gortexhq/gcx-go" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -205,7 +207,7 @@ func normalizeSearchGoldenRoots(got string, roots []string) string { } fields := strings.Split(line, "\t") if column < len(fields) { - fields[column] = normalizeSearchGoldenPath(fields[column], roots) + fields[column] = normalizeSearchGoldenGCXPath(fields[column], roots) lines[i+1] = strings.Join(fields, "\t") } } @@ -218,6 +220,27 @@ func normalizeSearchGoldenRoots(got string, roots []string) string { return got } +func normalizeSearchGoldenGCXPath(field string, roots []string) string { + // A GCX cell escapes backslashes itself. Decode ONLY this declared path + // cell; rebuilding the row would rewrite unrelated source/signature bytes. + decoder := wire.NewDecoder(strings.NewReader("GCX1 tool=golden fields=path_abs\n" + field + "\n")) + if row, err := decoder.NextRow(); err == nil { + path := row["path_abs"] + if normalized := normalizeSearchGoldenPath(path, roots); normalized != path { + var encoded bytes.Buffer + encoder := wire.NewEncoder(&encoded, wire.Header{Tool: "golden", Fields: []string{"path_abs"}}) + if encoder.WriteRow(normalized) == nil && encoder.Close() == nil { + if _, row, ok := strings.Cut(encoded.String(), "\n"); ok { + return strings.TrimSuffix(row, "\n") + } + } + } + } + // Retain compatibility with older hand-written raw-path fixtures. A + // non-owned path keeps its original escaped spelling byte for byte. + return normalizeSearchGoldenPath(field, roots) +} + func normalizeSearchGoldenPath(path string, roots []string) string { original := path path = strings.ReplaceAll(path, `\`, "/") From de62a3738a4635bd48b3245ac7501f2ae2f372ea Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 00:55:00 +0100 Subject: [PATCH 100/138] Align watcher executor tests with canonical owner dispatch --- internal/indexer/affected_by_bound_test.go | 29 +++++++++++++++---- internal/indexer/watcher_mutation_set_test.go | 8 +++++ .../watcher_publication_execution_test.go | 12 +++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/indexer/affected_by_bound_test.go b/internal/indexer/affected_by_bound_test.go index 95bcd9fb4..a8c0cd4f4 100644 --- a/internal/indexer/affected_by_bound_test.go +++ b/internal/indexer/affected_by_bound_test.go @@ -904,9 +904,9 @@ func TestPointMutationWithDeferredGlobalPassesPublishesNoVerdict(t *testing.T) { "a mutation that never ran the bounded pass published a verdict: %+v", result.DerivedFanout) } -// The daemon does NOT run the standalone point executor. MultiWatcher replaces -// the seam with MultiIndexer.incrementalPointRepoRaw (multi_watcher.go), which -// reaches the bounded pass through incrementalReindexRepoRawMode instead. Drive +// The daemon does NOT run the standalone point executor. Its registered +// Indexer's repositoryMutationOwner selects incrementalReindexRepoRawMode +// while retaining the watcher's relation-tier publication callback. Drive // that arm end to end so a change to the MultiIndexer executor's receipt or // catch-up handling cannot silently drop the verdict the shipped daemon // publishes. @@ -919,7 +919,18 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { multi := NewMultiIndexer(store, newTestRegistry(), nil, nil, zap.NewNop()) multi.repos[prefix] = &RepoMetadata{RepoPrefix: prefix, RootPath: dir} multi.indexers[prefix] = idx + // Keep the custom fan-out fixture, but bind the owner that the production + // MultiIndexer factory installs before registering an Indexer. + idx.repositoryMutationOwner = multi idx.attachRepositoryMutationCoordinator(multi.repositoryMutationCoordinator(prefix)) + var crossCalls atomic.Int32 + priorCatchup := idx.incrementalCatchupHook + idx.incrementalCatchupHook = func(kind string, files []string) { + priorCatchup(kind, files) + if kind == "cross_repo" { + crossCalls.Add(1) + } + } mw, err := NewMultiWatcher(multi, map[string]config.WatchConfig{ prefix: {Enabled: true, DebounceMs: 5}, @@ -927,8 +938,15 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { require.NoError(t, err) w := mw.watchers[prefix] require.NotNil(t, w, "MultiWatcher registered no watcher for %s", prefix) - require.NotNil(t, w.pointReindexRaw, - "the daemon arm under test is not installed; this would silently run the standalone executor") + // Explicit admission drives the real executor without starting fsnotify. + // Stop must close its handle without waiting for a loop that never ran. + w.degradedNoFsnotify = true + t.Cleanup(func() { require.NoError(t, w.Stop()) }) + current := w.currentMutationIndexer() + require.NotNil(t, current) + require.NotNil(t, current.repositoryMutationOwner, + "the daemon owner must be installed; otherwise this silently runs the standalone executor") + require.Same(t, multi, current.repositoryMutationOwner) bumpMtime(t, defPaths[0], "package p\n\nfunc F0(x int, y int) int { return x + y }\n") ticket, err := w.EnqueueFileMutation(context.Background(), defPaths[0]) @@ -944,6 +962,7 @@ func TestDaemonPointExecutorReportsTheDerivedFanoutCut(t *testing.T) { require.Len(t, truncationEntries(observed), 1, "the daemon arm did not truncate; the assertions below would be vacuous") + require.EqualValues(t, 1, crossCalls.Load(), "the real daemon resolver tail must run, not the standalone fallback") require.Empty(t, obs.uncarriedCuts()) require.NoError(t, result.Err) require.True(t, result.Reindexed, "the daemon-arm mutation did not apply: %+v", result) diff --git a/internal/indexer/watcher_mutation_set_test.go b/internal/indexer/watcher_mutation_set_test.go index 289ce6265..52766873b 100644 --- a/internal/indexer/watcher_mutation_set_test.go +++ b/internal/indexer/watcher_mutation_set_test.go @@ -46,9 +46,17 @@ func TestWatcherMutationSetParsesWholeFrontierBeforeResolution(t *testing.T) { _, err := idx.Index(dir) require.NoError(t, err) mi := &MultiIndexer{graph: g, repos: map[string]*RepoMetadata{"repo": {RepoPrefix: "repo", RootPath: dir, FileMtimes: idx.FileMtimes()}}, indexers: map[string]*Indexer{"repo": idx}, logger: zap.NewNop()} + // This hand-built Indexer bypasses the production MultiIndexer factory. + // Retain its controlled hooks while installing the factory's owner binding. + idx.repositoryMutationOwner = mi mw, err := NewMultiWatcher(mi, map[string]config.WatchConfig{"repo": {DebounceMs: 60000}}, zap.NewNop()) require.NoError(t, err) w := mw.watchers["repo"] + require.NotNil(t, w) + current := w.currentMutationIndexer() + require.NotNil(t, current) + require.NotNil(t, current.repositoryMutationOwner) + require.Same(t, mi, current.repositoryMutationOwner) w.degradedNoFsnotify = true t.Cleanup(func() { require.NoError(t, w.Stop()) }) writeTestFile(t, caller, "package pair\nfunc Caller(){Renamed()}\n") diff --git a/internal/indexer/watcher_publication_execution_test.go b/internal/indexer/watcher_publication_execution_test.go index 4976160f5..6ba7ca900 100644 --- a/internal/indexer/watcher_publication_execution_test.go +++ b/internal/indexer/watcher_publication_execution_test.go @@ -100,7 +100,16 @@ func TestCanonicalPublicationObservesMultiWatcherExecutor(t *testing.T) { }) } w := mw.watchers["repo-a"] - require.NotNil(t, w.pointReindexRaw, "production MultiWatcher must install the daemon's MultiIndexer executor") + idx := w.currentMutationIndexer() + require.NotNil(t, idx, "production MultiWatcher must select its registered Indexer") + require.NotNil(t, idx.repositoryMutationOwner, "production Indexer must bind the daemon executor") + require.Same(t, mi, idx.repositoryMutationOwner) + var crossCalls atomic.Int32 + idx.incrementalCatchupHook = func(kind string, _ []string) { + if kind == "cross_repo" { + crossCalls.Add(1) + } + } path := filepath.Join(root, "main.go") bumpMtime(t, path, "package main\n\nfunc ChangedByDaemonPath() {}\n") record := newPointPublication(t, "multi") @@ -108,6 +117,7 @@ func TestCanonicalPublicationObservesMultiWatcherExecutor(t *testing.T) { require.NoError(t, err) require.NoError(t, awaitMutationRetryResult(t, ticket).Err) require.NotNil(t, nodeByName(t, mi.GetIndexer("repo-a"), "repo-a/main.go", "ChangedByDaemonPath")) + require.EqualValues(t, 1, crossCalls.Load(), "the real daemon resolver tail must run, not the standalone fallback") requirePointExecution(t, record, true) } From 86b3f5bb2ac3404623aa3b25563876204c9ae69b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:20:08 +0100 Subject: [PATCH 101/138] Complete source mutation frontiers and exact acknowledgement evidence --- cmd/gortex/eval_recall.go | 2 +- cmd/gortex/eval_server.go | 2 +- docs/mcp.md | 39 ++ internal/analysis/scaffold.go | 37 +- internal/analysis/scaffold_test.go | 2 +- internal/config/config.go | 4 + internal/config/edit.go | 27 ++ internal/config/edit_ack_mode_test.go | 22 + internal/config/global.go | 4 + internal/daemon/overlay.go | 15 + internal/indexer/crash_isolation.go | 3 + internal/indexer/parse_errors.go | 9 +- internal/indexer/project_source.go | 207 +++++++++ internal/indexer/project_source_test.go | 321 +++++++++++++ internal/mcp/batch_commit_boundary_test.go | 119 +++++ .../mcp/batch_file_lifecycle_dry_run_test.go | 2 +- .../mcp/batch_pending_view_contract_test.go | 181 ++++++++ internal/mcp/batch_projection.go | 175 ++++++++ internal/mcp/batch_projection_budget_test.go | 115 +++++ internal/mcp/batch_transaction.go | 85 +++- internal/mcp/batch_transaction_journal.go | 28 +- .../mcp/checkout_mutation_receipt_test.go | 2 +- .../mcp/checkout_precommit_refusal_test.go | 10 +- .../contract_core_capability_table_test.go | 5 +- internal/mcp/dispatch_slot_release_test.go | 9 +- .../mcp/edit_acknowledgement_laps_test.go | 4 +- ...it_acknowledgement_tool_boundaries_test.go | 2 +- .../mcp/edit_acknowledgement_wire_test.go | 2 +- internal/mcp/edit_gate_seed_enabled.go | 2 + internal/mcp/edit_serialization.go | 330 ++++++++++---- internal/mcp/edit_serialization_test.go | 52 ++- internal/mcp/freshness_rider.go | 26 +- internal/mcp/move_file_observation.go | 152 +++++-- internal/mcp/move_file_observation_test.go | 178 ++++++-- .../move_read_assisted_observation_test.go | 2 +- internal/mcp/mutation_commit.go | 221 ++++++++- internal/mcp/mutation_commit_lock_test.go | 115 +++++ internal/mcp/mutation_commit_test.go | 28 +- internal/mcp/mutation_path_key_test.go | 92 ++++ internal/mcp/mutation_phase_record_test.go | 2 +- internal/mcp/mutation_release_wait_test.go | 71 +++ internal/mcp/node_source.go | 38 +- internal/mcp/overlay.go | 9 +- internal/mcp/parse_gate.go | 11 +- internal/mcp/pending_projection.go | 423 +++++++++++++++++ internal/mcp/pending_projection_cohort.go | 79 ++++ .../mcp/pending_projection_cohort_test.go | 80 ++++ .../pending_projection_retire_paths_test.go | 42 ++ internal/mcp/read_repair_observation_test.go | 4 +- .../mcp/read_repair_straddling_commit_test.go | 2 +- internal/mcp/rename_apply.go | 92 ++-- internal/mcp/safe_delete_propagate.go | 287 +++++++++--- internal/mcp/safe_delete_propagate_test.go | 131 ++++++ internal/mcp/server.go | 20 + internal/mcp/source_ack_mode_frontier_test.go | 213 +++++++++ internal/mcp/source_ack_mode_test.go | 121 +++++ internal/mcp/source_ack_policy.go | 73 +++ .../mcp/source_batch_frontier_wait_test.go | 81 ++++ internal/mcp/source_bypass_commit_test.go | 143 ++++++ internal/mcp/source_frontier_seed_test.go | 78 ++++ .../mcp/source_move_inline_frontier_test.go | 166 +++++++ internal/mcp/source_mutation_delta.go | 193 ++++++++ internal/mcp/source_mutation_delta_test.go | 208 +++++++++ internal/mcp/source_mutation_frontier.go | 229 ++++++++++ internal/mcp/source_mutation_frontier_test.go | 90 ++++ .../mcp/source_projection_ack_suspect_test.go | 102 +++++ .../mcp/source_projection_admission_test.go | 180 ++++++++ internal/mcp/source_projection_commit.go | 137 ++++++ internal/mcp/source_projection_file_view.go | 265 +++++++++++ .../mcp/source_projection_file_view_test.go | 119 +++++ internal/mcp/source_projection_frontier.go | 75 ++++ internal/mcp/source_projection_gate.go | 117 +++++ internal/mcp/source_projection_gate_test.go | 88 ++++ internal/mcp/source_projection_hash_test.go | 55 +++ .../mcp/source_projection_removal_test.go | 106 +++++ internal/mcp/source_projection_replay_test.go | 63 +++ internal/mcp/source_projection_retirement.go | 33 ++ ...rce_projection_retirement_callback_test.go | 113 +++++ internal/mcp/source_projection_span_test.go | 84 ++++ internal/mcp/source_projection_tiers_test.go | 46 ++ internal/mcp/source_projection_verify.go | 63 +++ internal/mcp/source_projection_verify_test.go | 45 ++ .../mcp/source_remaining_frontends_test.go | 360 +++++++++++++++ internal/mcp/source_rename_frontier_test.go | 144 ++++++ .../source_rename_parallel_projection_test.go | 113 +++++ internal/mcp/source_rename_projection.go | 89 ++++ .../source_rename_projection_cohort_test.go | 130 ++++++ .../mcp/source_safe_delete_frontier_test.go | 347 ++++++++++++++ internal/mcp/source_write_census_test.go | 18 +- internal/mcp/symbol_write_runtime_test.go | 75 ++++ internal/mcp/tool_deadline.go | 11 + internal/mcp/tools_coding.go | 214 ++++----- internal/mcp/tools_enhancements.go | 425 ++++++------------ internal/mcp/tools_fileops.go | 22 +- internal/mcp/tools_move_inline.go | 167 +++++-- internal/mcp/tools_mutation_status.go | 17 +- internal/mcp/tools_overlay_branch.go | 131 +++--- internal/mcp/tools_safe_delete.go | 306 +++++++++---- internal/mcp/tools_safe_delete_test.go | 10 +- internal/mcp/view_answer_identity_test.go | 2 +- .../mcp/view_mutation_known_write_test.go | 2 +- internal/mcp/view_mutation_state_test.go | 10 +- internal/mcp/view_mutation_write_intent.go | 22 +- .../mcp/view_mutation_write_intent_test.go | 17 + internal/mcp/view_publication_binding_test.go | 2 +- internal/parser/crashpool/crashpool.go | 20 +- internal/parser/crashpool/pool.go | 11 +- internal/parser/crashpool/pool_test.go | 38 ++ internal/parser/crashpool/worker.go | 15 +- internal/parser/extractor.go | 11 +- internal/parser/languages/c.go | 3 +- internal/parser/languages/golang.go | 22 +- internal/parser/languages/java.go | 3 +- .../parser/languages/parse_health_test.go | 68 +++ internal/parser/languages/python.go | 3 +- internal/parser/languages/rust.go | 3 +- internal/parser/languages/typescript.go | 3 +- internal/parser/parsetree.go | 17 +- internal/serverstack/shared_server.go | 1 + 119 files changed, 9058 insertions(+), 1002 deletions(-) create mode 100644 internal/config/edit.go create mode 100644 internal/config/edit_ack_mode_test.go create mode 100644 internal/indexer/project_source.go create mode 100644 internal/indexer/project_source_test.go create mode 100644 internal/mcp/batch_commit_boundary_test.go create mode 100644 internal/mcp/batch_pending_view_contract_test.go create mode 100644 internal/mcp/batch_projection.go create mode 100644 internal/mcp/batch_projection_budget_test.go create mode 100644 internal/mcp/mutation_commit_lock_test.go create mode 100644 internal/mcp/mutation_path_key_test.go create mode 100644 internal/mcp/mutation_release_wait_test.go create mode 100644 internal/mcp/pending_projection.go create mode 100644 internal/mcp/pending_projection_cohort.go create mode 100644 internal/mcp/pending_projection_cohort_test.go create mode 100644 internal/mcp/pending_projection_retire_paths_test.go create mode 100644 internal/mcp/source_ack_mode_frontier_test.go create mode 100644 internal/mcp/source_ack_mode_test.go create mode 100644 internal/mcp/source_ack_policy.go create mode 100644 internal/mcp/source_batch_frontier_wait_test.go create mode 100644 internal/mcp/source_bypass_commit_test.go create mode 100644 internal/mcp/source_frontier_seed_test.go create mode 100644 internal/mcp/source_move_inline_frontier_test.go create mode 100644 internal/mcp/source_mutation_delta.go create mode 100644 internal/mcp/source_mutation_delta_test.go create mode 100644 internal/mcp/source_mutation_frontier.go create mode 100644 internal/mcp/source_mutation_frontier_test.go create mode 100644 internal/mcp/source_projection_ack_suspect_test.go create mode 100644 internal/mcp/source_projection_admission_test.go create mode 100644 internal/mcp/source_projection_commit.go create mode 100644 internal/mcp/source_projection_file_view.go create mode 100644 internal/mcp/source_projection_file_view_test.go create mode 100644 internal/mcp/source_projection_frontier.go create mode 100644 internal/mcp/source_projection_gate.go create mode 100644 internal/mcp/source_projection_gate_test.go create mode 100644 internal/mcp/source_projection_hash_test.go create mode 100644 internal/mcp/source_projection_removal_test.go create mode 100644 internal/mcp/source_projection_replay_test.go create mode 100644 internal/mcp/source_projection_retirement.go create mode 100644 internal/mcp/source_projection_retirement_callback_test.go create mode 100644 internal/mcp/source_projection_span_test.go create mode 100644 internal/mcp/source_projection_tiers_test.go create mode 100644 internal/mcp/source_projection_verify.go create mode 100644 internal/mcp/source_projection_verify_test.go create mode 100644 internal/mcp/source_remaining_frontends_test.go create mode 100644 internal/mcp/source_rename_frontier_test.go create mode 100644 internal/mcp/source_rename_parallel_projection_test.go create mode 100644 internal/mcp/source_rename_projection.go create mode 100644 internal/mcp/source_rename_projection_cohort_test.go create mode 100644 internal/mcp/source_safe_delete_frontier_test.go create mode 100644 internal/mcp/symbol_write_runtime_test.go create mode 100644 internal/parser/languages/parse_health_test.go diff --git a/cmd/gortex/eval_recall.go b/cmd/gortex/eval_recall.go index 73e119ab0..5a65ed3d1 100644 --- a/cmd/gortex/eval_recall.go +++ b/cmd/gortex/eval_recall.go @@ -244,7 +244,7 @@ func runEvalRecall(_ *cobra.Command, _ []string) error { if selected["winnow"] { eng := query.NewEngine(g) eng.SetSearch(textBackend) - srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules, gortexmcp.MultiRepoOptions{EditAckMode: cfg.Edit.Ack}) srv.SetArchitecture(cfg.Architecture) srv.SetEventRules(cfg.Events.Rules) srv.SetArtifacts(cfg.Artifacts) diff --git a/cmd/gortex/eval_server.go b/cmd/gortex/eval_server.go index 8618a878f..cdc260230 100644 --- a/cmd/gortex/eval_server.go +++ b/cmd/gortex/eval_server.go @@ -68,7 +68,7 @@ func runEvalServer(cmd *cobra.Command, args []string) error { eng := query.NewEngine(g) eng.SetSearch(idx.Search()) gortexmcp.Version = version - srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules) + srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules, gortexmcp.MultiRepoOptions{EditAckMode: cfg.Edit.Ack}) defer srv.DrainBackground() srv.SetArchitecture(cfg.Architecture) srv.SetEventRules(cfg.Events.Rules) diff --git a/docs/mcp.md b/docs/mcp.md index 7fb094502..9e8eb3923 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -472,6 +472,45 @@ the first attests *what bytes* are on disk, the second whether the write happened at all. A call abandoned before its response returns no evidence block — only the receipt — which is exactly when `disk_status` is the field you need. +### Source mutation acknowledgement policy + +Set the daemon's startup configuration to choose when source edits acknowledge: + +```yaml +edit: + ack: full +``` + +`full` is the default. It waits for the complete admitted patch, for up to the +existing three-second publication budget. `local` returns after the disk commit, +exact file projection when available, and watcher admission. Both policies +release source path locks before waiting; a graph failure never reverses a disk +commit. Invalid modes are rejected. This is daemon configuration, not a tool +argument, and the response's `ack_mode` records the policy used for the operation. +Existing `require_fresh`, `freshness_scope`, and `wait_deadline` metadata can +request a specific tier under either policy. + +Each committed source-file response adds `commit_seq`, the actual raw-byte +`content_sha256` when known, `readback_exact`, and `graph_status_hint`. +`file_view` is an object: `state`, `delta_state`, `counts`, `symbols`, +`truncated`, and `syntax_health`. Available counts include `before`, `after`, +`added`, `removed`, `changed`, and `total_changes`; they remain complete when +`symbols` displays only the first 20 deterministic changes. Each displayed row +has `change`, `id`, `kind`, `start_line`, `end_line`, and `signature`. +An unauthenticated prior symbol set yields an unavailable delta and null counts. +Syntax health is checked only when the acknowledgement's parse supplies an exact +verdict; unknown health has `checked:false` and null `errors`. + +`readback_exact:true` requires that this write's projection is still live. +`tier_state.file` describes that physical file view independently of the +watcher-owned `file_graph`, `relations`, and `derived` stages. A newer write or +invalidation can make a replay's file view unavailable or historical. Replays +and `mutation_status` refresh live receipt evidence without parsing or writing +again. Transactional batches retain journal authority and report per-file +`file_views`; restarting does not reconstruct an original process-local +projection sequence from the journal. Report full/default and explicit-local +measurements separately; changing the mode does not establish a latency target. + ### Publication tiers and freshness `readback_exact:true` means the written bytes can be used immediately; continue diff --git a/internal/analysis/scaffold.go b/internal/analysis/scaffold.go index f10572fbb..f5a22020b 100644 --- a/internal/analysis/scaffold.go +++ b/internal/analysis/scaffold.go @@ -21,7 +21,7 @@ import ( // // This interface avoids a circular dependency with the indexer package. type SourceReader interface { - Graph() graph.Store + Graph() graph.Reader ResolveFilePath(graphPath string) string } @@ -62,7 +62,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN if absPath == "" { return nil, fmt.Errorf("could not resolve abs path for %s", node.FilePath) } - source, err := readSourceLines(absPath, node.StartLine, node.EndLine) + source, _, endLine, err := scaffoldSource(reader, node, absPath) if err != nil { return nil, fmt.Errorf("failed to read source for %s: %w", exampleID, err) } @@ -75,7 +75,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN // Add the new symbol edit — insert after the example symbol result.Edits = append(result.Edits, ScaffoldEdit{ FilePath: node.FilePath, - InsertionLine: node.EndLine + 1, + InsertionLine: endLine + 1, Code: substituted, Reason: "new_symbol", }) @@ -85,7 +85,7 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN callerNodes := filterCallerNodes(callerSG, exampleID) if len(callerNodes) > 0 { - regEdit := generateRegistrationCode(g, callerNodes, node, newName) + regEdit := generateRegistrationCode(g, reader, callerNodes, node, newName) if regEdit != nil { result.Edits = append(result.Edits, *regEdit) } @@ -104,6 +104,18 @@ func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newN return result, nil } +// A live caller can resolve declarations against the exact current bytes. +// Static analysis fixtures retain the ordinary source-reader contract. +func scaffoldSource(reader SourceReader, node *graph.Node, path string) (string, int, int, error) { + if live, ok := reader.(interface { + ScaffoldNodeSource(*graph.Node) (string, int, int, error) + }); ok { + return live.ScaffoldNodeSource(node) + } + text, err := readSourceLines(path, node.StartLine, node.EndLine) + return text, node.StartLine, node.EndLine, err +} + // readSourceLines reads lines [startLine, endLine] (1-indexed) from an // absolute file path. func readSourceLines(absPath string, startLine, endLine int) (string, error) { @@ -153,7 +165,7 @@ func filterCallerNodes(sg *query.SubGraph, exampleID string) []*graph.Node { // generateRegistrationCode creates a registration/wiring edit by analyzing how // the example symbol is called by its depth-1 callers. -func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *graph.Node, newName string) *ScaffoldEdit { +func generateRegistrationCode(g graph.Reader, reader SourceReader, callers []*graph.Node, example *graph.Node, newName string) *ScaffoldEdit { if len(callers) == 0 { return nil } @@ -176,10 +188,13 @@ func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *gra regCode := fmt.Sprintf("// TODO: Register %s following the pattern of %s\n// See %s at line %d for the registration pattern", newName, example.Name, caller.FilePath, callLine) - insertionLine := caller.EndLine - if callLine > 0 { - insertionLine = callLine + 1 + // The generated wiring is a comment, so insert outside the current + // declaration rather than applying an old edge line inside changed text. + _, _, end, err := scaffoldSource(reader, caller, reader.ResolveFilePath(caller.FilePath)) + if err != nil { + return nil } + insertionLine := end + 1 return &ScaffoldEdit{ FilePath: caller.FilePath, @@ -191,7 +206,7 @@ func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *gra // generateTestStub creates a test stub edit by finding the test file and test // functions associated with the example symbol. -func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, newName string) *ScaffoldEdit { +func generateTestStub(g graph.Reader, reader SourceReader, example *graph.Node, newName string) *ScaffoldEdit { testFilePath := deriveTestFilePath(example.FilePath) // Check if the test file exists on disk. Resolve abs path through @@ -223,7 +238,7 @@ func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, n } // Read the test function source and substitute the name - testSource, err := readSourceLines(absTestPath, testExample.StartLine, testExample.EndLine) + testSource, _, end, err := scaffoldSource(reader, testExample, absTestPath) if err != nil { return nil } @@ -232,7 +247,7 @@ func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, n return &ScaffoldEdit{ FilePath: testFilePath, - InsertionLine: testExample.EndLine + 1, + InsertionLine: end + 1, Code: substituted, Reason: "test_stub", } diff --git a/internal/analysis/scaffold_test.go b/internal/analysis/scaffold_test.go index 1ddd0d1b0..ff0e9a30f 100644 --- a/internal/analysis/scaffold_test.go +++ b/internal/analysis/scaffold_test.go @@ -22,7 +22,7 @@ type mockSourceReader struct { rootPath string } -func (m *mockSourceReader) Graph() graph.Store { return m.g } +func (m *mockSourceReader) Graph() graph.Reader { return m.g } func (m *mockSourceReader) ResolveFilePath(relPath string) string { if filepath.IsAbs(relPath) { return relPath diff --git a/internal/config/config.go b/internal/config/config.go index ce8a9b866..f868d3e9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -482,6 +482,7 @@ type Config struct { // workspaces. Only `mode: read-only` is accepted in iteration 1. CrossWorkspaceDeps []CrossWorkspaceDep `mapstructure:"cross_workspace_deps" yaml:"cross_workspace_deps,omitempty"` + Edit EditConfig `mapstructure:"edit" yaml:"edit,omitempty"` Index IndexConfig `mapstructure:"index" yaml:"index,omitempty"` Watch WatchConfig `mapstructure:"watch" yaml:"watch,omitempty"` Query QueryConfig `mapstructure:"query" yaml:"query,omitempty"` @@ -1965,6 +1966,9 @@ func Load(configPath string) (*Config, error) { // in one pass rather than one round-trip per fix. func (c *Config) validateWorkspaceSchema() error { var errs []string + if _, err := c.Edit.AckMode(); err != nil { + errs = append(errs, err.Error()) + } if c.Project != "" && len(c.Projects) > 0 { errs = append(errs, "config: 'project' and 'projects' are mutually exclusive — pick one") } diff --git a/internal/config/edit.go b/internal/config/edit.go new file mode 100644 index 000000000..c66c93662 --- /dev/null +++ b/internal/config/edit.go @@ -0,0 +1,27 @@ +package config + +import "fmt" + +type EditAckMode string + +const ( + EditAckFull EditAckMode = "full" + EditAckLocal EditAckMode = "local" +) + +// EditConfig controls the source mutation reply policy. Empty retains the +// shipped full mode; local is always an explicit operator choice. +type EditConfig struct { + Ack EditAckMode `mapstructure:"ack" yaml:"ack,omitempty"` +} + +func (e EditConfig) AckMode() (EditAckMode, error) { + switch e.Ack { + case "", EditAckFull: + return EditAckFull, nil + case EditAckLocal: + return EditAckLocal, nil + default: + return "", fmt.Errorf("config: edit.ack must be full or local, got %q", e.Ack) + } +} diff --git a/internal/config/edit_ack_mode_test.go b/internal/config/edit_ack_mode_test.go new file mode 100644 index 000000000..c0ca884c4 --- /dev/null +++ b/internal/config/edit_ack_mode_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInvalidEditAckConfigurationRefusesLoading(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("edit:\n ack: immediate\n"), 0o600)) + t.Run("workspace", func(t *testing.T) { + _, err := Load(path) + require.ErrorContains(t, err, "edit.ack") + }) + t.Run("global", func(t *testing.T) { + _, err := LoadGlobal(path) + require.ErrorContains(t, err, "edit.ack") + }) +} diff --git a/internal/config/global.go b/internal/config/global.go index c8ef165a9..1f9d20585 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -68,6 +68,7 @@ type ProjectConfig struct { // GlobalConfig is the user-level config at ~/.gortex/config.yaml. type GlobalConfig struct { + Edit EditConfig `mapstructure:"edit" yaml:"edit,omitempty"` Projects map[string]ProjectConfig `mapstructure:"projects" yaml:"projects,omitempty"` Repos []RepoEntry `mapstructure:"repos" yaml:"repos,omitempty"` ActiveProject string `mapstructure:"active_project" yaml:"active_project,omitempty"` @@ -347,6 +348,9 @@ func LoadGlobal(configPath ...string) (*GlobalConfig, error) { return nil, fmt.Errorf("parsing global config: %w", err) } + if _, err := gc.Edit.AckMode(); err != nil { + return nil, err + } gc.configPath = path return gc, nil } diff --git a/internal/daemon/overlay.go b/internal/daemon/overlay.go index f7e2bce0c..5d9176a98 100644 --- a/internal/daemon/overlay.go +++ b/internal/daemon/overlay.go @@ -853,6 +853,10 @@ type MergeOptions struct { Into string ToDisk bool Force bool + // ExpectedFiles is a bounded source snapshot checked under the branch + // mutex before the first disk callback. Nil retains legacy callers; + // a non-nil empty slice requires an empty source branch. + ExpectedFiles []OverlayFile } // DiskWriteFn is the callback MergeBranches invokes for each From @@ -926,6 +930,17 @@ func (m *OverlayManager) MergeBranches(sessionID string, opts MergeOptions, writ } if opts.ToDisk { + if opts.ExpectedFiles != nil { + if len(opts.ExpectedFiles) != len(src.files) { + return MergeResult{}, errors.New("merge: source branch changed while waiting for disk paths") + } + for _, expected := range opts.ExpectedFiles { + actual, ok := src.files[expected.Path] + if !ok || !overlayFilesEqual(actual, expected) { + return MergeResult{}, errors.New("merge: source branch changed while waiting for disk paths") + } + } + } if write == nil { return MergeResult{}, errors.New("merge: disk write callback is required for to_disk merges") } diff --git a/internal/indexer/crash_isolation.go b/internal/indexer/crash_isolation.go index 70b65b640..c014984f8 100644 --- a/internal/indexer/crash_isolation.go +++ b/internal/indexer/crash_isolation.go @@ -367,6 +367,9 @@ func (idx *Indexer) extractFileCtxWithRawLease( // and has since been fixed, drop the stale entry. q.Forget(relPath) result = &parser.ExtractionResult{Nodes: res.Nodes, Edges: res.Edges} + if res.ParseChecked && res.ParseErrors >= 0 { + result.ParseChecked, result.ParseErrors = true, res.ParseErrors + } if res.HasParseErr { stampParseErrorCount(result.Nodes, res.ParseErrors) } diff --git a/internal/indexer/parse_errors.go b/internal/indexer/parse_errors.go index f3936c025..a0d6e7820 100644 --- a/internal/indexer/parse_errors.go +++ b/internal/indexer/parse_errors.go @@ -15,7 +15,14 @@ import ( // done) get no stamp — absence of the meta key means "unknown", not // "clean". func stampParseErrors(result *parser.ExtractionResult) { - if result == nil || result.Tree == nil { + if result == nil { + return + } + if result.ParseChecked && result.ParseErrors >= 0 { + stampParseErrorCount(result.Nodes, result.ParseErrors) + return + } + if result.Tree == nil { return } if !result.Tree.HasParseErrors() { diff --git a/internal/indexer/project_source.go b/internal/indexer/project_source.go new file mode 100644 index 000000000..02438a348 --- /dev/null +++ b/internal/indexer/project_source.go @@ -0,0 +1,207 @@ +package indexer + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// projectionLanguages are the languages whose single-file projection equals a +// full index of the same bytes: node IDs, kinds and line ranges. The golden +// parity test pins the list; a language that drifts leaves it. +var projectionLanguages = map[string]bool{ + "go": true, + "typescript": true, + "python": true, + "c": true, + "java": true, + "rust": true, +} + +// projectionBuildDeadline bounds one build, including the wait for a parse +// slot. A projection that cannot be built in time is not worth an edit's ack. +const projectionBuildDeadline = 200 * time.Millisecond + +// projectionParseAdmission is reserved for projection builds, apart from the +// shared budget bulk indexing fills, so an edit's projection never queues +// behind a cold index. It admits two maximum-size sources at once. +var projectionParseAdmission = newParseAdmissionBudget(2 * maxAdHocExtractSourceBytes) + +// Reasons a source is not projected. Callers fall back to the store. +var ( + ErrProjectionLanguage = errors.New("projection: language not projected") + ErrProjectionTransform = errors.New("projection: source transform does not preserve coordinates") + ErrProjectionTooLarge = errors.New("projection: source over the size limit") + ErrProjectionNoSlot = errors.New("projection: no parse slot before the deadline") + ErrProjectionDeadline = errors.New("projection: build past the deadline") + ErrProjectionRefused = errors.New("projection: extraction refused") +) + +// Projection is the graph content one file contributes to the index, built +// from caller-owned bytes without reading the store: the file's nodes and the +// edges they record, stamped as the indexer would stamp them. Edges keep their +// unresolved targets; resolution is cross-file work the projection skips. +type Projection struct { + GraphPath string + Language string + // ContentHash is computed over the transformed bytes with the same + // function as FileMetaRow.ContentHash, so the two are equal once the + // index holds these bytes. + ContentHash string + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool // zero errors are trustworthy only with the explicit full-parse proof + BuildTime time.Duration +} + +// ProjectSource builds the projection of src, the bytes a tool just wrote to +// absPath. It runs the indexer's own transforms, extraction and coverage +// passes, so the nodes equal what a full index stores for the file. Sources +// the indexer would treat differently from a plain parse, and builds that +// cannot finish within projectionBuildDeadline, return one of the +// ErrProjection errors. +func (idx *Indexer) ProjectSource(ctx context.Context, absPath string, src []byte) (*Projection, error) { + started := time.Now() + if idx == nil || idx.registry == nil { + return nil, fmt.Errorf("indexer source projection is unavailable") + } + limit := int64(maxAdHocExtractSourceBytes) + if idx.config.MaxFileSize > 0 && idx.config.MaxFileSize < limit { + limit = idx.config.MaxFileSize + } + if int64(len(src)) > limit { + return nil, ErrProjectionTooLarge + } + language, ok := idx.effectiveLanguage(absPath, src) + if !ok || !projectionLanguages[language] { + return nil, ErrProjectionLanguage + } + extractor, ok := idx.registry.GetByLanguage(language) + if !ok { + return nil, ErrProjectionLanguage + } + if _, err := idx.transforms.prepareCoordinateStable(absPath, src); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProjectionTransform, err) + } + + ctx, cancel := context.WithTimeout(ctx, projectionBuildDeadline) + defer cancel() + relPath := idx.relKey(absPath) + // No command transform matches this path, so run only applies the + // built-in passes the index applies; hashing its output reproduces + // the stored content hash. + transformed := idx.transforms.run(relPath, src) + rawLease, err := acquireParseAdmission(ctx, int64(len(transformed)), 0, nil, projectionParseAdmission) + if err != nil { + return nil, ErrProjectionNoSlot + } + // A parse cannot be cancelled, so a build still running at the deadline + // is left to finish in the background. It keeps its lease until then, + // which bounds how many abandoned builds can pile up. + type extraction struct { + result *parser.ExtractionResult + err error + } + done := make(chan extraction, 1) + go func() { + defer rawLease.Release() + result, err := idx.extractSourceWith( + ctx, rawLease, nil, + absPath, relPath, language, extractor, transformed, + ) + done <- extraction{result, err} + }() + var built extraction + select { + case built = <-done: + case <-ctx.Done(): + go func() { + if late := <-done; late.result != nil { + late.result.ReleaseTree() + } + }() + return nil, ErrProjectionDeadline + } + switch { + case built.err != nil && ctx.Err() != nil: + return nil, ErrProjectionDeadline + case built.err != nil: + return nil, fmt.Errorf("%w: %v", ErrProjectionRefused, built.err) + } + result := built.result + defer result.ReleaseTree() + + if !extractionDispositionFor(result).omitSecondarySourceScans() { + idx.applyCoverageDomains(relPath, language, transformed, result) + } + idx.applyRepoPrefix(result.Nodes, result.Edges) + parseErrors := 0 + for _, n := range result.Nodes { + if n != nil && n.Kind == graph.KindFile { + parseErrors, _ = n.Meta["parse_errors"].(int) + break + } + } + if result.ParseChecked && result.ParseErrors >= 0 { + parseErrors = result.ParseErrors + } + if time.Since(started) > projectionBuildDeadline { + return nil, ErrProjectionDeadline + } + return &Projection{ + GraphPath: idx.prefixPath(relPath), + Language: language, + ContentHash: contentHashForSource(transformed), + Nodes: result.Nodes, + Edges: result.Edges, + ParseErrors: parseErrors, + ParseChecked: result.ParseChecked && result.ParseErrors >= 0, + BuildTime: time.Since(started), + }, nil +} + +// DiffProjectedNodes lists how a projection's nodes differ from the nodes the +// store holds for the same file, compared by ID, kind and line range. It is +// empty when they agree. +func DiffProjectedNodes(projected, stored []*graph.Node) []string { + type span struct { + kind graph.NodeKind + start, end int + } + want := make(map[string]span, len(stored)) + for _, n := range stored { + if n != nil { + want[n.ID] = span{n.Kind, n.StartLine, n.EndLine} + } + } + var diffs []string + seen := make(map[string]bool, len(projected)) + for _, n := range projected { + // The store keeps one node per ID, and so does a layer. + if n == nil || seen[n.ID] { + continue + } + seen[n.ID] = true + got := span{n.Kind, n.StartLine, n.EndLine} + have, ok := want[n.ID] + switch { + case !ok: + diffs = append(diffs, fmt.Sprintf("only projected: %s %s %d-%d", n.ID, n.Kind, n.StartLine, n.EndLine)) + case have != got: + diffs = append(diffs, fmt.Sprintf("differs: %s projected %s %d-%d, stored %s %d-%d", + n.ID, got.kind, got.start, got.end, have.kind, have.start, have.end)) + } + delete(want, n.ID) + } + for id, s := range want { + diffs = append(diffs, fmt.Sprintf("only stored: %s %s %d-%d", id, s.kind, s.start, s.end)) + } + sort.Strings(diffs) + return diffs +} diff --git a/internal/indexer/project_source_test.go b/internal/indexer/project_source_test.go new file mode 100644 index 000000000..bc4058d93 --- /dev/null +++ b/internal/indexer/project_source_test.go @@ -0,0 +1,321 @@ +package indexer + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +// projectionFixtures holds one small source per language with the shapes a +// projection must reproduce: imports, constants, types, methods, calls and +// a TODO the coverage pass turns into a node. +var projectionFixtures = map[string]string{ + "go.mod": "module example.com/parity\n\ngo 1.22\n", + "sample.go": `package parity + +import ( + "fmt" + "strings" +) + +// TODO: tighten the limit. +const Limit = 3 + +type Greeter struct { + Name string +} + +func (g *Greeter) Greet(other string) string { + return fmt.Sprintf("%s greets %s", g.Name, strings.ToUpper(other)) +} + +func helper(n int) int { + if n > Limit { + return n + } + return helper(n + 1) +} + +var defaultGreeter = &Greeter{Name: "x"} +`, + // The index strips a byte-order mark before parsing and hashing. + "bom.go": "\xef\xbb\xbfpackage parity\n\nfunc Marked() int { return helper(1) }\n", + "web/sample.ts": `import { readFileSync } from "fs"; + +export const LIMIT = 3; + +export interface Shape { + area(): number; +} + +export class Square implements Shape { + constructor(private side: number) {} + area(): number { + return this.side * this.side; + } +} + +export function load(path: string): string { + // TODO: cache the result + return readFileSync(path, "utf8"); +} +`, + "py/sample.py": `import os + +LIMIT = 3 + + +class Greeter: + def __init__(self, name): + self.name = name + + def greet(self, other): + return helper(other) + + +def helper(value): + # TODO: validate the value + return os.path.join(value, "x") +`, + "c/sample.c": `#include + +#define LIMIT 3 + +struct point { + int x; + int y; +}; + +static int helper(int n) { + return n + LIMIT; +} + +int main(void) { + struct point p = {1, 2}; + printf("%d\n", helper(p.x)); + return 0; +} +`, + "java/sample/Greeter.java": `package sample; + +import java.util.List; + +public class Greeter { + private static final int LIMIT = 3; + private final String name; + + public Greeter(String name) { + this.name = name; + } + + public String greet(List others) { + return name + others.size() + LIMIT; + } +} +`, + "rust/src/lib.rs": `use std::fmt; + +pub const LIMIT: u32 = 3; + +pub struct Greeter { + name: String, +} + +impl Greeter { + pub fn greet(&self, other: &str) -> String { + format!("{} {}", self.name, helper(other)) + } +} + +fn helper(s: &str) -> String { + s.to_uppercase() +} + +impl fmt::Display for Greeter { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} +`, +} + +func newProjectionTestIndexer(t *testing.T, g graph.Store, cfg config.IndexConfig) *Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg.Workers = 1 + idx := New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +func writeProjectionFixtures(t *testing.T, dir string) { + t.Helper() + for rel, content := range projectionFixtures { + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } +} + +// The projection of a file equals what a full index stores for it, node for +// node, and carries the same content hash as the file's metadata row. Every +// language on the allowlist must hold this; the rest must refuse. +func TestProjectSourceGoldenParity(t *testing.T) { + dir := t.TempDir() + writeProjectionFixtures(t, dir) + g := graph.New() + idx := newProjectionTestIndexer(t, g, config.Default().Index) + idx.SetRepoPrefix("parity") + idx.SetWorkspaceID("ws") + idx.SetProjectID("proj") + _, err := idx.Index(dir) + require.NoError(t, err) + + for rel := range projectionFixtures { + if rel == "go.mod" { + continue + } + t.Run(rel, func(t *testing.T) { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + src, err := os.ReadFile(abs) + require.NoError(t, err) + lang, ok := idx.effectiveLanguage(abs, src) + require.True(t, ok) + + projection, err := idx.ProjectSource(context.Background(), abs, src) + if !projectionLanguages[lang] { + require.ErrorIs(t, err, ErrProjectionLanguage) + return + } + require.NoError(t, err) + graphPath := "parity/" + rel + require.Equal(t, graphPath, projection.GraphPath) + require.NotEmpty(t, projection.Nodes) + require.True(t, projection.ParseChecked) + require.Zero(t, projection.ParseErrors) + require.Empty(t, DiffProjectedNodes(projection.Nodes, g.GetFileNodes(graphPath))) + for _, n := range projection.Nodes { + require.Equal(t, "parity", n.RepoPrefix, n.ID) + require.Equal(t, "ws", n.WorkspaceID, n.ID) + require.Equal(t, "proj", n.ProjectID, n.ID) + } + + rows, err := g.FileMetasByPaths("parity", []string{graphPath}) + require.NoError(t, err) + require.Equal(t, rows[graphPath].ContentHash, projection.ContentHash) + }) + } +} + +type slowGoExtractor struct{ delay time.Duration } + +func (slowGoExtractor) Language() string { return "go" } +func (slowGoExtractor) Extensions() []string { return []string{".go"} } +func (e slowGoExtractor) Extract(path string, _ []byte) (*parser.ExtractionResult, error) { + time.Sleep(e.delay) + return &parser.ExtractionResult{Nodes: []*graph.Node{{ID: path, Kind: graph.KindFile, FilePath: path}}}, nil +} + +func TestProjectSourceRefusals(t *testing.T) { + dir := t.TempDir() + goSource := []byte("package p\n\nfunc F() {}\n") + + t.Run("language off the allowlist", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "x.rb"), []byte("def f\nend\n")) + require.ErrorIs(t, err, ErrProjectionLanguage) + }) + t.Run("over 1 MiB", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + big := append(bytes.Clone(goSource), bytes.Repeat([]byte("// padding\n"), (1<<20)/11+1)...) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "big.go"), big) + require.ErrorIs(t, err, ErrProjectionTooLarge) + }) + t.Run("command transform", func(t *testing.T) { + cfg := config.Default().Index + cfg.Transforms = []config.TransformRule{{ + Name: "must-not-run", + Extensions: []string{".go"}, + Command: []string{"gortex-test-transform-must-not-run"}, + }} + idx := newProjectionTestIndexer(t, graph.New(), cfg) + idx.SetRootPath(dir) + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + require.ErrorIs(t, err, ErrProjectionTransform) + }) + t.Run("build past the deadline", func(t *testing.T) { + const parse = 1500 * time.Millisecond + registry := parser.NewRegistry() + registry.Register(slowGoExtractor{delay: parse}) + idx := New(graph.New(), registry, config.Default().Index, zap.NewNop()) + t.Cleanup(idx.Close) + idx.SetRootPath(dir) + started := time.Now() + _, err := idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + require.ErrorIs(t, err, ErrProjectionDeadline) + // The caller hears back at the deadline, not when the parse ends. + require.Less(t, time.Since(started), parse/2) + }) + t.Run("no parse slot", func(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + held, err := acquireParseAdmission(t.Context(), projectionParseAdmission.capacity, 0, nil, projectionParseAdmission) + require.NoError(t, err) + _, err = idx.ProjectSource(t.Context(), filepath.Join(dir, "f.go"), goSource) + held.Release() + require.ErrorIs(t, err, ErrProjectionNoSlot) + }) +} + +// Bulk indexing can fill the shared parse budget for minutes. A projection +// build has its own reserve and must not queue behind it. +func TestInteractiveExtractionNotBlockedByFullSharedParseAdmission(t *testing.T) { + dir := t.TempDir() + writeProjectionFixtures(t, dir) + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + idx.SetRootPath(dir) + raw, native := newParseAdmissionBudget(1<<20), newParseAdmissionBudget(1<<20) + idx.parseAdmission.Store(raw) + idx.nativeParseAdmission.Store(native) + for _, budget := range []*parseAdmissionBudget{raw, native} { + held, err := acquireParseAdmission(t.Context(), budget.capacity, 0, nil, budget) + require.NoError(t, err) + t.Cleanup(held.Release) + } + + for _, rel := range []string{"sample.go", "c/sample.c"} { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + src, err := os.ReadFile(abs) + require.NoError(t, err) + projection, err := idx.ProjectSource(t.Context(), abs, src) + require.NoError(t, err, rel) + require.True(t, strings.HasSuffix(projection.GraphPath, rel)) + require.NotEmpty(t, projection.Nodes) + } +} + +func TestProjectSourceUnknownVerdictCannotCertifyClean(t *testing.T) { + idx := newProjectionTestIndexer(t, graph.New(), config.Default().Index) + root := t.TempDir() + idx.SetRootPath(root) + idx.registry.Register(slowGoExtractor{}) + projection, err := idx.ProjectSource(context.Background(), filepath.Join(root, "main.go"), []byte("package p\n")) + require.NoError(t, err) + require.False(t, projection.ParseChecked) + require.Zero(t, projection.ParseErrors) +} diff --git a/internal/mcp/batch_commit_boundary_test.go b/internal/mcp/batch_commit_boundary_test.go new file mode 100644 index 000000000..12ecdd535 --- /dev/null +++ b/internal/mcp/batch_commit_boundary_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/agents" +) + +func TestBatchRollbackRequiresLiveHeldRecoveryPaths(t *testing.T) { + for _, mode := range []string{"held", "cancelled-held", "unheld", "released", "wrong-path"} { + t.Run(mode, func(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + receipt := prepareAtomicRecoveryFixture(t, s, "held-restore", []string{path}, []string{"before\n"}, []string{"after\n"}) + require.NoError(t, agents.AtomicWriteFile(path, []byte("after\n"), 0o644)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if mode != "unheld" { + lockedPath := path + if mode == "wrong-path" { + lockedPath = filepath.Join(filepath.Dir(path), "other.txt") + } + var release func() + var err error + ctx, release, err = acquireMutationPath(ctx, lockedPath) + require.NoError(t, err) + defer release() + if mode == "released" { + release() + } + } + if mode == "cancelled-held" { + cancel() // restore is terminalization of already committed bytes + } + status, err := s.rollbackBatchReceipt(receipt, ctx) + current, readErr := os.ReadFile(path) + require.NoError(t, readErr) + if mode == "held" || mode == "cancelled-held" { + require.NoError(t, err) + require.Equal(t, "aborted", status) + require.Equal(t, "before\n", string(current)) + } else { + assert.Error(t, err, "restore must prove that its original recovery path set is still held") + assert.Equal(t, "recovery_conflict", status) + assert.Equal(t, "after\n", string(current), "a refused restore must not touch source bytes") + } + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical, "the durable batch remains the only source authority") + }) + } +} + +func TestBatchForwardWritesKeepTransactionAsOnlySourceAuthority(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + ctx, note := withMutationCommitNote(t.Context()) + ctx, ack, _ := s.withEditAcknowledgement(ctx) + defer ack.finishActual() + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical, "a forward file write must not pre-empt the transaction verdict") + require.Empty(t, note.verdict().Snapshots, "an abandoned batch must use its durable transaction status") + return agents.AtomicWriteFile(path, content, mode) + } + receipt, err := s.runBatchTransaction(ctx, []batchEditItem{atomicFileEdit(path, "before", "after")}, "only-batch-authority") + require.NoError(t, err) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "committed", receipt.DiskStatus) + require.Equal(t, "after\n", readAtomicBatchFixture(t, path)) + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical) + require.Empty(t, note.verdict().Snapshots) +} + +func TestBatchSourceOperationsKeepHeldAuthorityThroughTerminalization(t *testing.T) { + for _, kind := range []sourceMutationOperationKind{batchForwardWrite, batchForwardRemove, batchRestoreWrite, batchRestoreRemove} { + for _, held := range []bool{false, true} { + t.Run(fmt.Sprintf("operation_%d/held_%t", kind, held), func(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "source.txt", "before\n") + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + if held { + var release func() + var err error + ctx, release, err = acquireMutationPath(ctx, path) + require.NoError(t, err) + defer release() + } + cancel() // a prepared transaction must finish after this boundary + record, err := s.commitFileMutation(ctx, "batch_edit", "", "", "source.txt", path, []byte("after\n"), 0o644, sourceMutationOperation{kind: kind}) + require.Nil(t, record) + if !held { + require.ErrorIs(t, err, errMutationNotApplied) + require.Equal(t, "before\n", readAtomicBatchFixture(t, path)) + } else { + require.NoError(t, err) + if kind == batchForwardRemove || kind == batchRestoreRemove { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } else { + require.Equal(t, "after\n", readAtomicBatchFixture(t, path)) + } + } + _, physical := s.mutationCommits.recentForPath(path) + require.False(t, physical) + require.Empty(t, note.verdict().Snapshots) + }) + } + } +} diff --git a/internal/mcp/batch_file_lifecycle_dry_run_test.go b/internal/mcp/batch_file_lifecycle_dry_run_test.go index bc4745de4..d65710e7e 100644 --- a/internal/mcp/batch_file_lifecycle_dry_run_test.go +++ b/internal/mcp/batch_file_lifecycle_dry_run_test.go @@ -535,7 +535,7 @@ func TestBatchEditDryRunTakesNoMutationPathLock(t *testing.T) { source := writeAtomicBatchFixture(t, repoRoot, "source.txt", "source\n") destination := filepath.Join(repoRoot, "destination.txt") - release, err := acquireMutationPaths(context.Background(), []string{source, destination}) + _, release, err := acquireMutationPaths(context.Background(), []string{source, destination}) if err != nil { t.Fatal(err) } diff --git a/internal/mcp/batch_pending_view_contract_test.go b/internal/mcp/batch_pending_view_contract_test.go new file mode 100644 index 000000000..b240bad0b --- /dev/null +++ b/internal/mcp/batch_pending_view_contract_test.go @@ -0,0 +1,181 @@ +package mcp + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +// Exercise the durable writer boundary rather than manually constructing a +// projection cohort. A partial physical forward write is not batch authority. +func TestBatchPendingViewsRequireCompleteDurableFrontier(t *testing.T) { + for _, rollback := range []bool{false, true} { + name := "commit" + if rollback { + name = "rollback" + } + t.Run(name, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + a := filepath.Join(root, "main.go") + b := writeAtomicBatchFixture(t, root, "second.go", "package main\nfunc SecondBefore() {}\n") + _, err := s.indexer.IncrementalReindexPaths(root, []string{b}) + require.NoError(t, err) + beforeA := readAtomicBatchFixture(t, a) + beforeB := readAtomicBatchFixture(t, b) + writes, failed := 0, false + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + if failed { + return durableAtomicWriteFile(path, content, mode) + } + writes++ + require.NoError(t, durableAtomicWriteFile(path, content, mode)) + if writes == 1 { + require.Nil(t, liveEntry(pendingFileProjections, a), "one successful writer cannot publish half a batch") + require.Nil(t, liveEntry(pendingFileProjections, b), "unwritten sibling cannot become a pending view") + require.Nil(t, s.graph.GetNode("main.go::BatchFirstAfter")) + require.Nil(t, s.graph.GetNode("second.go::BatchSecondAfter")) + } + if writes == 2 && rollback { + failed = true + return errors.New("deliberate second forward writer failure after bytes changed") + } + return nil + } + s.batchDurabilityOverride = &batchDurabilityOps{syncDirectory: func(path string) error { + if filepath.Clean(path) == filepath.Clean(root) { + require.Nil(t, liveEntry(pendingFileProjections, a), "directory durability must precede cache admission") + require.Nil(t, liveEntry(pendingFileProjections, b)) + } + return syncBatchDirectory(path) + }} + res := callBatchEdit(t, s, map[string]any{"transaction_id": "pending-view-" + name, "edits": []any{ + map[string]any{"op": "edit_file", "path": a, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc BatchFirstAfter() {}"}, + map[string]any{"op": "edit_file", "path": b, "old_string": "func SecondBefore() {}", "new_string": "func BatchSecondAfter() {}"}, + }}) + var receipt batchTransactionReceipt + require.NoError(t, json.Unmarshal([]byte(readText(t, res)), &receipt)) + require.GreaterOrEqual(t, writes, 2) + if rollback { + require.Equal(t, "rolled_back", receipt.DiskStatus) + require.Equal(t, beforeA, readAtomicBatchFixture(t, a)) + require.Equal(t, beforeB, readAtomicBatchFixture(t, b)) + require.Nil(t, liveEntry(pendingFileProjections, a)) + require.Nil(t, liveEntry(pendingFileProjections, b)) + return + } + require.Equal(t, "committed", receipt.DiskStatus) + require.Equal(t, "pending", receipt.GraphStatus) + first, second := liveEntry(pendingFileProjections, a), liveEntry(pendingFileProjections, b) + require.NotNil(t, first, "durable batch bytes need a pending file view before the watcher finishes") + require.NotNil(t, second, "the complete durable frontier must publish together") + require.NotNil(t, graph.NewOverlaidView(s.graph, first.layer).GetNode("main.go::BatchFirstAfter")) + require.NotNil(t, graph.NewOverlaidView(s.graph, second.layer).GetNode("second.go::BatchSecondAfter")) + require.Positive(t, first.commitSeq) + require.Equal(t, first.commitSeq, second.commitSeq, "one successful durable frontier has one process-local sequence") + response := s.batchProjectionResponse(receipt) + require.Equal(t, first.commitSeq, response.CommitSeq) + require.Len(t, response.FileViews, 2) + for _, view := range response.FileViews { + require.True(t, view.ReadbackExact) + require.Equal(t, "projected", view.FileView.State) + require.NotEmpty(t, view.ContentSHA256) + require.Equal(t, "file current; recorded graph status: pending", view.GraphStatusHint) + } + journal, err := os.ReadFile(batchManifestPath(receipt.TransactionID)) + require.NoError(t, err) + require.NotContains(t, string(journal), "commit_seq", "process-local projection origin is never journal authority") + status := callBatchEdit(t, s, map[string]any{"transaction_id": receipt.TransactionID, "status_only": true}) + require.False(t, status.IsError) + require.Equal(t, 2, writes, "status cannot repeat forward source effects") + require.Equal(t, response.CommitSeq, s.batchProjectionResponse(receipt).CommitSeq) + restarted := &Server{watcher: mutationTestWatcher{done: done}, session: newSessionState(), mutationReindexWait: time.Nanosecond} + recovered, err := restarted.batchTransactionStatus(t.Context(), receipt.TransactionID) + require.NoError(t, err) + require.Equal(t, "committed", recovered.DiskStatus) + restartResponse := restarted.batchProjectionResponse(recovered) + require.Zero(t, restartResponse.CommitSeq) + for _, view := range restartResponse.FileViews { + require.False(t, view.ReadbackExact, "even an existing live cache cannot reconstruct an original process-local origin") + require.Equal(t, "unavailable", view.FileView.State) + } + require.Nil(t, s.graph.GetNode("main.go::BatchFirstAfter"), "pending file evidence is not store freshness") + require.Nil(t, s.graph.GetNode("second.go::BatchSecondAfter")) + }) + } +} + +func TestBatchPendingCohortRefusalKeepsEveryPriorEntry(t *testing.T) { + for _, reason := range []string{"file-cap", "byte-cap", "newer-pointer"} { + t.Run(reason, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + a := filepath.Join(root, "main.go") + b := writeAtomicBatchFixture(t, root, "sibling.go", "package main\nfunc SiblingBefore() {}\n") + _, err := s.indexer.IncrementalReindexPaths(root, []string{b}) + require.NoError(t, err) + if reason == "file-cap" { + for i := 0; i < projectionMaxFiles-1; i++ { + name := fmt.Sprintf("filler-%03d.go", i) + require.NoError(t, pendingFileProjections.publish(syntheticProjectionEntry(filepath.Join(root, name), name, "h", 1, diskStamp{}, nil))) + } + } + if reason == "byte-cap" { + filler := syntheticProjectionEntry(filepath.Join(root, "filler.go"), "filler.go", "h", 1, diskStamp{}, nil) + filler.size = projectionMaxBytes - 1 + require.NoError(t, pendingFileProjections.publish(filler)) + } + before := pendingFileProjections.snap.Load() + var newer *projectionEntry + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + if err := durableAtomicWriteFile(path, content, mode); err != nil { + return err + } + if reason == "newer-pointer" && path == a { + stamp, err := statDiskStamp(a) + require.NoError(t, err) + newer = syntheticProjectionEntry(a, "main.go", "newer", ^uint64(0), stamp, content) + require.NoError(t, pendingFileProjections.publish(newer)) + before = pendingFileProjections.snap.Load() + } + return nil + } + result := callBatchEdit(t, s, map[string]any{"transaction_id": "refuse-" + reason, "edits": []any{ + map[string]any{"op": "edit_file", "path": a, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc RefusedFirst() {}"}, + map[string]any{"op": "edit_file", "path": b, "old_string": "func SiblingBefore() {}", "new_string": "func RefusedSecond() {}"}, + }}) + var receipt batchTransactionReceipt + require.NoError(t, json.Unmarshal([]byte(readText(t, result)), &receipt)) + require.Equal(t, "committed", receipt.DiskStatus, "cache refusal never reverses a durable batch") + require.Same(t, before, pendingFileProjections.snap.Load(), "one rejected member cannot replace any prior entry") + require.Same(t, newer, liveEntry(pendingFileProjections, a)) + require.Nil(t, liveEntry(pendingFileProjections, b)) + for _, view := range s.batchProjectionResponse(receipt).FileViews { + require.False(t, view.ReadbackExact) + require.Equal(t, "unavailable", view.FileView.State) + } + }) + } +} diff --git a/internal/mcp/batch_projection.go b/internal/mcp/batch_projection.go new file mode 100644 index 000000000..64a706b7a --- /dev/null +++ b/internal/mcp/batch_projection.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "github.com/zzet/gortex/internal/pathkey" + "sync" + "time" +) + +// Preparations live only on the original transaction's stack. The existing +// bounded projection registry is the sole owner of admitted file graphs. +func (s *Server) prepareBatchProjections(ctx context.Context, buffers map[string]*batchFileBuffer, paths []string) map[string]*preparedFileProjection { + if len(paths) > projectionMaxFiles { + return nil + } + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + workCtx, cancel := context.WithCancel(ctx) + defer cancel() + results := make([]*preparedFileProjection, len(paths)) + jobs := make(chan int) + var workers sync.WaitGroup + var budgetMu sync.Mutex + var preparedBytes int64 + projectedFiles, exceeded := 0, false + parsed := false + for range min(3, len(paths)) { + workers.Go(func() { + for { + select { + case <-workCtx.Done(): + return + case i, ok := <-jobs: + if !ok { + return + } + path := paths[i] + buffer := buffers[path] + var candidate *preparedFileProjection + parserOwned := false + if buffer.existsAfter { + built, projection := s.buildFileProjection(workCtx, path, buffer.content) + candidate = built + parserOwned = projection != nil + } else { + candidate = s.buildRemovalProjection(workCtx, buffer.relPath, path) + } + budgetMu.Lock() + parsed = parsed || parserOwned + if candidate != nil && candidate.entry != nil { + preparedBytes += candidate.entry.size + projectedFiles++ + if projectedFiles > projectionMaxFiles || preparedBytes > projectionMaxBytes { + exceeded = true + cancel() + } + } + if !exceeded && workCtx.Err() == nil { + results[i] = candidate + } + budgetMu.Unlock() + } + } + }) + } +feed: + for i := range paths { + select { + case jobs <- i: + case <-workCtx.Done(): + break feed + } + } + close(jobs) + workers.Wait() // no cohort worker outlives cancellation or a cap refusal + lap.applicable = parsed + lap.finish() // baseline/delta metadata is deliberately outside parse WALL + if parsed { + s.noteAcknowledgementParser(ctx, "source_projection_parse_gate") + } else { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") + } + if exceeded || workCtx.Err() != nil { + return nil + } + prepared := make(map[string]*preparedFileProjection, len(paths)) + preparedBytes = 0 + for i, path := range paths { + if ctx.Err() != nil { + return nil + } + buffer := buffers[path] + candidate := results[i] + s.prepareFileViewEvidence(ctx, candidate, &plannedSource{path: path, before: buffer.original, existed: buffer.existsBefore}, buffer.content) + if candidate != nil && candidate.entry != nil { + preparedBytes += candidate.entry.size + if preparedBytes > projectionMaxBytes { + return nil + } + } + prepared[path] = results[i] // stable original frontier order, independent of workers + } + return prepared +} + +// Called only after every forward effect and directory sync has succeeded, +// while the original physical union is still held. No sequence enters the +// durable receipt/journal; recovery cannot invent this process-local origin. +func (s *Server) publishBatchProjections(ctx context.Context, state *batchTransactionState, receipt batchTransactionReceipt, prepared map[string]*preparedFileProjection) { + sequence := mutationDiskCommitSequence.Add(1) + state.mu.Lock() + state.projectionCommitSeq = sequence + state.mu.Unlock() + members := verifyBatchProjectionCohort(ctx, receipt, prepared, sequence, s.verifyPreparedProjection) + _ = pendingFileProjections.publishCohort(members) // cache refusal never changes disk authority +} + +// One deadline covers the complete readback frontier, never the durable writer. +func verifyBatchProjectionCohort(ctx context.Context, receipt batchTransactionReceipt, prepared map[string]*preparedFileProjection, sequence uint64, verify func(context.Context, *preparedFileProjection, uint64, string) (*projectionEntry, error)) []projectionCohortMember { + verifyCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) + defer cancel() + members := make([]projectionCohortMember, 0, len(receipt.Files)) + for _, file := range receipt.Files { + candidate := prepared[file.Path] + if candidate == nil || candidate.entry == nil { + continue + } + entry, err := verify(verifyCtx, candidate, sequence, file.AfterSHA256) + if err != nil || verifyCtx.Err() != nil { + return nil + } + members = append(members, projectionCohortMember{Entry: entry, Expected: candidate.expected}) + } + return members +} + +type batchProjectionFileView struct { + Path string `json:"path"` + ContentSHA256 string `json:"content_sha256,omitempty"` + ReadbackExact bool `json:"readback_exact"` + FileView mutationFileView `json:"file_view"` + GraphStatusHint string `json:"graph_status_hint"` +} + +type batchProjectionResponse struct { + batchAcknowledgementResponse + CommitSeq uint64 `json:"commit_seq,omitempty"` + FileViews []batchProjectionFileView `json:"file_views,omitempty"` +} + +func (s *Server) batchProjectionResponse(receipt batchTransactionReceipt) batchProjectionResponse { + response := batchProjectionResponse{batchAcknowledgementResponse: s.batchAcknowledgementResponse(receipt)} + if value, ok := s.batchTransactions.Load(receipt.TransactionID); ok { + if state, ok := value.(*batchTransactionState); ok { + state.mu.RLock() + response.CommitSeq = state.projectionCommitSeq + state.mu.RUnlock() + } + } + if receipt.Status != "committed" || receipt.DiskStatus != "committed" { + return response + } + for _, file := range receipt.Files { + view := batchProjectionFileView{Path: file.Path, ContentSHA256: file.AfterSHA256, + FileView: mutationFileView{State: "unavailable", Reason: "projection_not_live", DeltaState: "unavailable", DeltaReason: "projection_not_live", SyntaxHealth: fileViewSyntaxHealth{State: "unknown"}}} + key := pathkey.MutationKey(file.Path) + entry := pendingFileProjections.snap.Load().entries[key] + if response.CommitSeq != 0 && entry != nil && entry.commitSeq == response.CommitSeq && + entry.contentSHA256 == file.AfterSHA256 && entry.tombstone == file.AfterAbsent && liveProjectionForAcknowledgement(entry) { + view.ReadbackExact, view.FileView = true, fileViewForEntry(entry) + } + view.GraphStatusHint = mutationFileGraphHint(map[string]any{"graph_status": receipt.GraphStatus}, view.ReadbackExact) + response.FileViews = append(response.FileViews, view) + } + return response +} diff --git a/internal/mcp/batch_projection_budget_test.go b/internal/mcp/batch_projection_budget_test.go new file mode 100644 index 000000000..4a9444fb4 --- /dev/null +++ b/internal/mcp/batch_projection_budget_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "path/filepath" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestBatchProjectionVerificationSharesOneDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + prepared := map[string]*preparedFileProjection{ + "a": {entry: &projectionEntry{}}, "b": {entry: &projectionEntry{}}, "c": {entry: &projectionEntry{}}, + } + receipt := batchTransactionReceipt{Files: []batchTransactionFile{{Path: "a"}, {Path: "b"}, {Path: "c"}}} + calls := 0 + start := time.Now() + members := verifyBatchProjectionCohort(t.Context(), receipt, prepared, 7, func(ctx context.Context, p *preparedFileProjection, seq uint64, sha string) (*projectionEntry, error) { + calls++ + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, start.Add(150*time.Millisecond), deadline) + select { + case <-time.After(100 * time.Millisecond): + return p.entry, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }) + require.Nil(t, members, "deadline cannot publish the verified prefix alone") + require.Equal(t, 2, calls, "no fresh budget for later members") + require.Equal(t, 150*time.Millisecond, time.Since(start)) + }) +} + +type batchProjectionOverlapExtractor struct { + parser.Extractor + active atomic.Bool + entered chan struct{} + release chan struct{} + finished chan struct{} +} + +func (e *batchProjectionOverlapExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.active.Load() { + e.entered <- struct{}{} + <-e.release + defer func() { e.finished <- struct{}{} }() + } + return e.Extractor.Extract(path, raw) +} + +func TestBatchProjectionPreparationOverlapsRealExtractions(t *testing.T) { + extractor := &batchProjectionOverlapExtractor{Extractor: languages.NewGoExtractor(), entered: make(chan struct{}, 2), release: make(chan struct{}), finished: make(chan struct{}, 2)} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + a, b := filepath.Join(root, "main.go"), filepath.Join(root, "other.go") + ctx, ack, _ := s.withEditAcknowledgement(t.Context()) + ctx, unlock, err := acquireMutationPaths(ctx, []string{a, b}) + require.NoError(t, err) + defer unlock() + buffers := map[string]*batchFileBuffer{ + a: {absPath: a, relPath: "main.go", original: []byte("package main\nfunc helper() {}\n"), content: []byte("package main\nfunc ParallelFirst() {}\n"), existsBefore: true, existsAfter: true}, + b: {absPath: b, relPath: "other.go", content: []byte("package main\nfunc ParallelSecond() {}\n"), existsAfter: true}, + } + extractor.active.Store(true) + released := false + defer func() { + if !released { + close(extractor.release) + } + }() + result := make(chan map[string]*preparedFileProjection, 1) + go func() { result <- s.prepareBatchProjections(ctx, buffers, []string{a, b}) }() + for range 2 { + select { + case <-extractor.entered: + case <-time.After(2 * time.Second): + t.Fatal("both native extractions did not enter") + } + } + close(extractor.release) + released = true + var got map[string]*preparedFileProjection + select { + case got = <-result: + case <-time.After(2 * time.Second): + t.Fatal("batch preparation did not join") + } + for range 2 { + select { + case <-extractor.finished: + case <-time.After(2 * time.Second): + t.Fatal("owned native extraction did not finish") + } + } + require.NotNil(t, got[a]) + require.NotNil(t, got[b]) + require.NotNil(t, got[a].entry, "a serial build expired before its sibling could enter") + require.NotNil(t, got[b].entry) + ack.mu.Lock() + defer ack.mu.Unlock() + require.EqualValues(t, 1, ack.laps.data[editAckParseGate].started) + require.EqualValues(t, 1, ack.laps.data[editAckParseGate].completed) + require.False(t, ack.laps.overlap, "concurrent files share one wall lap, never nested per-file laps") + require.False(t, ack.laps.data[editAckParseGate].invalid) + require.Equal(t, "source_projection_parse_gate", ack.parserScope) +} diff --git a/internal/mcp/batch_transaction.go b/internal/mcp/batch_transaction.go index cdf735cc7..f2b8242a4 100644 --- a/internal/mcp/batch_transaction.go +++ b/internal/mcp/batch_transaction.go @@ -16,6 +16,7 @@ import ( "time" "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/pathkey" @@ -64,6 +65,7 @@ type batchTransactionFile struct { } type batchTransactionReceipt struct { + AckMode string `json:"ack_mode,omitempty"` Version int `json:"version"` CheckoutGeneration int64 `json:"checkout_generation,omitempty"` HeadRef string `json:"checkout_head_ref,omitempty"` @@ -98,6 +100,8 @@ type batchTransactionState struct { // A scalar reference only: the bounded mutation ledger owns timing data. // This is neither durable journal metadata nor a pointer to an RPC history. acknowledgementReceipt string + // Scalar only; never persisted or reconstructed from durable journal state. + projectionCommitSeq uint64 } // cloneBatchReceipt returns a receipt that shares no slice or map storage with @@ -1068,7 +1072,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, paths = append(paths, plan.destinationPath) } } - release, lockErr := acquireMutationPaths(ctx, paths) + ctx, release, lockErr := acquireMutationPaths(ctx, paths) if lockErr != nil { receipt.Results = batchFailureResults(plans, 0, "edit cancelled while waiting for exclusive file access: "+lockErr.Error()) receipt.Summary = batchSummary(receipt.Results) @@ -1093,6 +1097,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if applyErr != nil { return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", applyErr.Error()), nil } + preparedProjections := s.prepareBatchProjections(ctx, buffers, orderedPaths) if err := s.prepareBatchJournal(&receipt, buffers, orderedPaths); err != nil { return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "could not persist transaction journal: "+err.Error()), nil } @@ -1120,18 +1125,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, // Commit is deliberately non-cancellable. Once the first rename succeeds, // every remaining write or rollback must run to a terminal disk state. - writer := s.batchDurability().writeFile - remover := s.batchDurability().removeFile - if s.batchWriteOverride != nil { - // Preserve the target-only fault-injection seam used by commit tests; - // journal and rollback writes always retain the durability discipline. - writer = s.batchWriteOverride - } - if s.batchRemoveOverride != nil { - remover = s.batchRemoveOverride - } fileObservation = s.beginBatchFileObservation(ctx, receipt, buffers, orderedPaths) - writer = s.observedBatchFileWriter(fileObservation, writer) finishCommitFailure := func(failedPath, message string) batchTransactionReceipt { status, rollbackErr := s.rollbackBatchReceipt(receipt, ctx) if rollbackErr != nil { @@ -1161,10 +1155,10 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if targetErr := s.validateBatchCreateTarget(ctx, path, buffer.relPath); targetErr != nil { commitErr = targetErr } else { - commitErr = s.writeAcknowledgedBatchFile(ctx, writer, path, buffer.content, buffer.mode) + _, commitErr = s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, buffer.content, buffer.mode, sourceMutationOperation{kind: batchForwardWrite, observation: fileObservation}) } } else { - commitErr = s.writeAcknowledgedBatchFile(ctx, writer, path, buffer.content, buffer.mode) + _, commitErr = s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, buffer.content, buffer.mode, sourceMutationOperation{kind: batchForwardWrite, observation: fileObservation}) } if commitErr != nil { message := fmt.Sprintf("could not commit %s: %v", buffer.relPath, commitErr) @@ -1180,7 +1174,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if err := guardCheckoutMutationPath(ctx, path); err != nil { return finishCommitFailure(buffer.relPath, err.Error()), nil } - if commitErr := s.removeAcknowledgedBatchFile(ctx, remover, path); commitErr != nil { + if _, commitErr := s.commitFileMutation(ctx, "batch_edit", "", "", buffer.relPath, path, nil, 0, sourceMutationOperation{kind: batchForwardRemove}); commitErr != nil { message := fmt.Sprintf("could not commit %s: %v", buffer.relPath, commitErr) return finishCommitFailure(buffer.relPath, message), nil } @@ -1202,6 +1196,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, if persistErr := s.persistBatchManifest(receipt); persistErr != nil { receipt.Error = "disk committed; terminal journal update failed: " + persistErr.Error() } + s.publishBatchProjections(context.WithoutCancel(ctx), state, receipt, preparedProjections) state.publish(receipt, false) s.finishBatchFileObservation(fileObservation, receipt) @@ -1215,7 +1210,7 @@ func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, session.recordSymbol(plan.edit.SymbolID) } } - s.refreshBatchGraph(ctx, state) + s.refreshBatchGraphReleasing(ctx, state, release) return state.snapshot(), nil } @@ -1266,7 +1261,16 @@ func (s *Server) batchTransactionStatus(ctx context.Context, transactionID strin return state.snapshot(), nil } +// Compatibility entry for callers which do not own a source frontier. func (s *Server) beginBatchGraphRefresh(observationCtx context.Context, absPath string, transaction batchTransactionReceipt) mutationReindexOutcome { + admission := s.admitBatchGraphRefresh(observationCtx, absPath, transaction) + if admission.inlinePath != "" { + return mutationReindexOutcome{Reindexed: s.reindexFile(admission.inlinePath)} + } + return admission.immediate +} + +func (s *Server) admitBatchGraphRefresh(observationCtx context.Context, absPath string, transaction batchTransactionReceipt) sourceMutationAdmission { ctx := context.Background() // preserve the original scheduler's lifetime if watcher := s.currentWatcher(); watcher != nil { if scheduler, ok := watcher.(mutationScheduler); ok { @@ -1282,17 +1286,17 @@ func (s *Server) beginBatchGraphRefresh(observationCtx context.Context, absPath ticket, err := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(ctx, publication), absPath) lap.finish() if err != nil { - return mutationReindexOutcome{Err: err} + return sourceMutationAdmission{immediate: mutationReindexOutcome{Err: err}} } if ticket != nil { receipt := s.trackMutationTicket(ticket) s.bindBatchFilePublication(record, receipt.outcome(true)) s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) - return receipt.outcome(true) + return sourceMutationAdmission{immediate: receipt.outcome(true)} } } } - return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} + return sourceMutationAdmission{inlinePath: absPath} } // batchReceiptsPublished closes once every tracked receipt of files has @@ -1390,6 +1394,13 @@ func (s *Server) waitBatchGraphReceipts(ctx context.Context, files []batchTransa } func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionState) { + s.refreshBatchGraphReleasing(ctx, state, func() {}) +} + +// Original forward path ownership ends only after the full durable frontier +// has been admitted. Publication/inline compatibility work cannot retain it. +func (s *Server) refreshBatchGraphReleasing(ctx context.Context, state *batchTransactionState, releasePaths func()) { + defer releasePaths() // Acquire checkout authority before graphMu, matching the initial commit // and prepared recovery. Waiting for a lease while holding graphMu would // deadlock a committer that owns that lease and is admitting its ticket. @@ -1424,6 +1435,7 @@ func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionS return } outcomes := make(map[string]mutationReindexOutcome, len(receipt.Files)) + var inlinePaths []string // A durable transaction readmits its complete frontier after any receipt loss. // Live receipt sets retain their original status and generation identities. needsAdmission := false @@ -1482,15 +1494,39 @@ func (s *Server) refreshBatchGraph(ctx context.Context, state *batchTransactionS file.ReindexReceipt = "" file.ReindexGeneration = 0 } - outcome := s.beginBatchGraphRefresh(ctx, file.Path, receipt) + admission := s.admitBatchGraphRefresh(ctx, file.Path, receipt) + if admission.inlinePath != "" { + inlinePaths = append(inlinePaths, admission.inlinePath) + } + outcome := admission.immediate outcomes[file.Path] = outcome file.ReindexReceipt = outcome.Receipt file.ReindexGeneration = outcome.Generation } + releasePaths() + for _, path := range inlinePaths { + outcomes[path] = mutationReindexOutcome{Reindexed: s.reindexFile(path)} + } + // Admit the entire file set before waiting. The bounded wait is shared by // the batch, rather than multiplied by the number of files. - s.waitBatchGraphReceipts(ctx, receipt.Files) + policy := s.sourceAckPolicy(ctx) + // A journal captures the original transaction choice; manifests from + // before the option existed retain the historical full policy. + policy.mode = config.EditAckFull + if receipt.AckMode == string(config.EditAckLocal) { + policy.mode = config.EditAckLocal + } + if policy.freshness.requireFresh { + paths := make([]string, 0, len(receipt.Files)) + for _, file := range receipt.Files { + paths = append(paths, file.Path) + } + _ = s.awaitSourceAckTier(ctx, policy, paths, s.batchReceiptsPublished(ctx, receipt.Files)) + } else if !policy.local() { + s.waitBatchGraphReceipts(ctx, receipt.Files) + } graphStatus := "fresh" for i := range receipt.Files { file := &receipt.Files[i] @@ -1571,17 +1607,17 @@ func (s *Server) finishBatchTransaction(state *batchTransactionState, receipt ba } func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") args := req.GetArguments() transactionID, _ := args["transaction_id"].(string) statusOnly, _ := args["status_only"].(bool) rawEdits, hasEdits := args["edits"] if statusOnly || !hasEdits || rawEdits == nil { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") receipt, err := s.batchTransactionStatus(ctx, transactionID) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - response := s.batchAcknowledgementResponse(receipt) + response := s.batchProjectionResponse(receipt) if isCompact(req) { return mcp.NewToolResultText(fmt.Sprintf("%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus) + response.referenceLine()), nil } @@ -1596,6 +1632,7 @@ func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequ return mcp.NewToolResultError("edits array is empty"), nil } if dryRun, _ := args["dry_run"].(bool); dryRun { + s.noteAcknowledgementParser(ctx, "not_applicable_text_splice") // Paths are resolved exactly as the commit resolves them, symbol edits // included: without that a symbol edit has no path, and the rules that // compare paths — lifecycle overlap, then aliasing — see a shorter @@ -1647,7 +1684,7 @@ func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequ if err != nil { return mcp.NewToolResultError(err.Error()), nil } - response := s.batchAcknowledgementResponse(receipt) + response := s.batchProjectionResponse(receipt) if isCompact(req) { var out strings.Builder fmt.Fprintf(&out, "%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus) diff --git a/internal/mcp/batch_transaction_journal.go b/internal/mcp/batch_transaction_journal.go index 7bff2877a..3acdbb6be 100644 --- a/internal/mcp/batch_transaction_journal.go +++ b/internal/mcp/batch_transaction_journal.go @@ -13,6 +13,7 @@ import ( "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/gitstate" + "github.com/zzet/gortex/internal/pathkey" ) const batchTransactionDirEnv = "GORTEX_BATCH_TRANSACTION_DIR" @@ -153,6 +154,7 @@ func (s *Server) loadOrCreateBatchTransaction(ctx context.Context, transactionID return nil, "", fmt.Errorf("transaction %q not found", transactionID) } receipt := batchTransactionReceipt{ + AckMode: string(s.sourceAckPolicy(ctx).mode), Version: batchTransactionVersion, TransactionID: transactionID, Fingerprint: fingerprint, Status: "preparing", DiskStatus: "unchanged", GraphStatus: "not_started", StartedAt: time.Now().UTC(), } @@ -273,12 +275,16 @@ func classifyBatchFiles(files []batchTransactionFile) (before, after, unknown [] return before, after, unknown, err } -func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts ...context.Context) (string, error) { - for _, ctx := range contexts { - for _, file := range receipt.Files { - if err := guardCheckoutMutationPath(ctx, file.Path); err != nil { - return "recovery_conflict", err - } +func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, ctx context.Context) (string, error) { + // Prove the entire original recovery set before restoring any file. The + // transaction still owns rollback and durability; no per-file ordinary + // commit record may advertise a partial restore as source authority. + for _, file := range receipt.Files { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(file.Path)) { + return "recovery_conflict", fmt.Errorf("%w: rollback source path is not held for mutation", errMutationNotApplied) + } + if err := guardCheckoutMutationPath(ctx, file.Path); err != nil { + return "recovery_conflict", err } } _, after, unknown, classifyErr := classifyBatchFiles(receipt.Files) @@ -287,7 +293,7 @@ func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts } for _, file := range after { if file.BeforeAbsent { - if err := s.batchDurability().removeFile(file.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + if _, err := s.commitFileMutation(ctx, "batch_edit", "", "", file.RelativePath, file.Path, nil, 0, sourceMutationOperation{kind: batchRestoreRemove}); err != nil && !errors.Is(err, os.ErrNotExist) { return "recovery_conflict", fmt.Errorf("remove rollback-created %s: %w", file.RelativePath, err) } continue @@ -296,7 +302,7 @@ func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt, contexts if err != nil { return "recovery_conflict", fmt.Errorf("load rollback backup for %s: %w", file.RelativePath, err) } - if err := s.batchDurability().writeFile(file.Path, backup, file.Mode); err != nil { + if _, err := s.commitFileMutation(ctx, "batch_edit", "", "", file.RelativePath, file.Path, backup, file.Mode, sourceMutationOperation{kind: batchRestoreWrite}); err != nil { return "recovery_conflict", fmt.Errorf("restore %s: %w", file.RelativePath, err) } } @@ -340,7 +346,7 @@ func (s *Server) recoverBatchTransaction(ctx context.Context, state *batchTransa } recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() - release, err := acquireMutationPaths(recoveryCtx, paths) + recoveryCtx, release, err := acquireMutationPaths(recoveryCtx, paths) if err != nil { s.finishBatchTransaction(state, receipt, "recovery_conflict", "conflict", "not_started", "could not acquire recovery locks: "+err.Error()) return @@ -374,12 +380,12 @@ func (s *Server) recoverBatchTransaction(ctx context.Context, state *batchTransa receipt.Recovered = true s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "recovered prepared transaction before commit") default: - if err := prepareBatchRecovery(ctx); err != nil { + if err := prepareBatchRecovery(recoveryCtx); err != nil { receipt.Error = err.Error() state.publish(receipt, true) return } - status, rollbackErr := s.rollbackBatchReceipt(receipt, ctx) + status, rollbackErr := s.rollbackBatchReceipt(receipt, recoveryCtx) receipt.Recovered = true message := "recovered interrupted mixed commit by restoring original bytes" if rollbackErr != nil { diff --git a/internal/mcp/checkout_mutation_receipt_test.go b/internal/mcp/checkout_mutation_receipt_test.go index 89a78996b..a7c88ea13 100644 --- a/internal/mcp/checkout_mutation_receipt_test.go +++ b/internal/mcp/checkout_mutation_receipt_test.go @@ -278,7 +278,7 @@ func TestCheckoutMutationReceiptBindsBytesActuallyCommitted(t *testing.T) { s := &Server{} ctx := withCheckoutMutation(context.Background(), mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) if err != nil { t.Fatal(err) } diff --git a/internal/mcp/checkout_precommit_refusal_test.go b/internal/mcp/checkout_precommit_refusal_test.go index 0194f9b78..15041bfe6 100644 --- a/internal/mcp/checkout_precommit_refusal_test.go +++ b/internal/mcp/checkout_precommit_refusal_test.go @@ -109,7 +109,7 @@ func TestCheckoutPrecommitRefusalExcludesMultiFileAndAppliedScopes(t *testing.T) require.NoError(t, os.WriteFile(state.committedPath, []byte("applied earlier\n"), 0o644)) } f.srv.mutationPreCommitHook = func(*mutationCommitRecord) { movePrecommitHEAD(t, f) } - record, err := f.srv.commitFileMutation(ctx, tc.tool, "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, tc.tool, "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) require.ErrorIs(t, err, indexer.ErrCheckoutMutationStale) require.ErrorIs(t, err, errMutationNotApplied) require.Equal(t, mutationDiskNotApplied, record.diskStatus()) @@ -154,7 +154,7 @@ func TestCheckoutPrecommitRefusalRejectsUnprovenErrors(t *testing.T) { lease := &primitiveCheckoutMutation{prepareErr: cause} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "edit_file", "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "edit_file", "", "", "edit.go", filepath.Join(f.worktree, "edit.go"), []byte("package repo\n"), 0o644) require.ErrorIs(t, err, cause) require.Equal(t, mutationDiskNotApplied, record.diskStatus()) require.NotContains(t, mutationNotAppliedMessage("edit", record, err), "Native read/edit is allowed") @@ -173,7 +173,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing lease := &primitiveCheckoutMutation{prepareErr: indexer.ErrCheckoutMutationStale} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "utf16.go", path, []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "utf16.go", path, []byte("package repo\n"), 0o644) require.ErrorContains(t, err, "UTF-16") require.Zero(t, lease.prepared, "encoding refusal must not borrow a lease refusal") require.Equal(t, mutationDiskNotApplied, record.diskStatus()) @@ -187,7 +187,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing lease := &primitiveCheckoutMutation{} ctx := withCheckoutMutation(t.Context(), lease, f.worktree) checkoutMutationFromContext(ctx).singleFile = true - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "directory.go", path, []byte("package repo\n"), 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "directory.go", path, []byte("package repo\n"), 0o644) require.Error(t, err) require.Equal(t, 1, lease.prepared) require.Equal(t, mutationDiskFailed, record.diskStatus()) @@ -201,7 +201,7 @@ func TestCheckoutPrecommitRefusalDoesNotExtendToEncodingIOOrPostwrite(t *testing state := checkoutMutationFromContext(ctx) state.singleFile = true data := []byte("package repo\n") - record, err := f.srv.commitFileMutation(ctx, "write_file", "", "", "postwrite.go", path, data, 0o644) + record, err := f.srv.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "postwrite.go", path, data, 0o644) require.NoError(t, err) require.Equal(t, mutationDiskCommitted, record.diskStatus()) outcome := f.srv.refreshCheckoutMutation(ctx, path, state) diff --git a/internal/mcp/contract_core_capability_table_test.go b/internal/mcp/contract_core_capability_table_test.go index 771f455e1..88c3131a1 100644 --- a/internal/mcp/contract_core_capability_table_test.go +++ b/internal/mcp/contract_core_capability_table_test.go @@ -232,6 +232,9 @@ func contractCoreCapabilityTable() []contractCoreCapRow { {"OverlayLayerContractFileNodesReader", capOf[graph.OverlayLayerContractFileNodesReader], capDropped, "checked physical-layer file nodes; graph.ContractFileNodesContext unwraps and composes the selected reader with its ownership masks instead of forwarding physical rows on the request wrapper"}, {"ContractStateStore", capOf[graph.ContractStateStore], capDropped, capReasonBackend}, {"FileMetaReader", capOf[graph.FileMetaReader], capDropped, capReasonBackend}, + {"FileMetaPathReader", capOf[graph.FileMetaPathReader], capDropped, "projection retirement reads durable content evidence on the backend, never a request reader"}, + {"local:FileMetasByPaths+GetFileNodes", capOf[projectionStore], capDropped, capReasonBackend}, + {"ConstantValueProjectionReader", capOf[graph.ConstantValueProjectionReader], capDropped, "selected retirement reads checked metadata through the same leased handoff; never forwarded through contract-core request readers"}, {"RepoIndexStateReader", capOf[graph.RepoIndexStateReader], capDropped, capReasonBackend}, {"StructuralIntegrityAuditor", capOf[graph.StructuralIntegrityAuditor], capDropped, capReasonBackend}, {"StructuralIntegritySnapshotter", capOf[graph.StructuralIntegritySnapshotter], capDropped, capReasonBackend}, @@ -274,7 +277,6 @@ func contractCoreUnprobedCapabilities() []contractCoreCapName { {"CloneShingleWriter", capOf[graph.CloneShingleWriter]}, {"ConfigNodeBatchEvicter", capOf[graph.ConfigNodeBatchEvicter]}, {"ConstantValueContextReader", capOf[graph.ConstantValueContextReader]}, - {"ConstantValueProjectionReader", capOf[graph.ConstantValueProjectionReader]}, {"ConstantValueReader", capOf[graph.ConstantValueReader]}, {"ConstantValueRepoReplacer", capOf[graph.ConstantValueRepoReplacer]}, {"ConstantValueWriter", capOf[graph.ConstantValueWriter]}, @@ -314,7 +316,6 @@ func contractCoreUnprobedCapabilities() []contractCoreCapName { {"FileIndexFailureReader", capOf[graph.FileIndexFailureReader]}, {"FileIndexFailureWriter", capOf[graph.FileIndexFailureWriter]}, {"FileLanguageNodeSequencer", capOf[graph.FileLanguageNodeSequencer]}, - {"FileMetaPathReader", capOf[graph.FileMetaPathReader]}, {"FileMetaRepoReplacer", capOf[graph.FileMetaRepoReplacer]}, {"FileMetaWriter", capOf[graph.FileMetaWriter]}, {"FileMtimeDeleter", capOf[graph.FileMtimeDeleter]}, diff --git a/internal/mcp/dispatch_slot_release_test.go b/internal/mcp/dispatch_slot_release_test.go index 3dd812323..37d501f8a 100644 --- a/internal/mcp/dispatch_slot_release_test.go +++ b/internal/mcp/dispatch_slot_release_test.go @@ -319,7 +319,14 @@ func TestRenameReturnsTheDispatchSlotOnlyAtItsLastFile(t *testing.T) { return true } - results := s.commitRenameWrites(context.Background(), writes) + paths := make([]string, len(writes)) + for i, w := range writes { + paths[i] = w.AbsPath + } + heldCtx, releasePaths, err := acquireMutationPaths(context.Background(), paths) + require.NoError(t, err) + defer releasePaths() + results := s.commitRenameWrites(heldCtx, writes, releasePaths) require.Len(t, results, len(writes)) for _, result := range results { require.Equal(t, "applied", result["status"]) diff --git a/internal/mcp/edit_acknowledgement_laps_test.go b/internal/mcp/edit_acknowledgement_laps_test.go index eb5b0ba8a..6805811b8 100644 --- a/internal/mcp/edit_acknowledgement_laps_test.go +++ b/internal/mcp/edit_acknowledgement_laps_test.go @@ -143,7 +143,7 @@ func TestEditAcknowledgementRealDiskAndCanonicalTicketBoundaries(t *testing.T) { var record *mutationCommitRecord h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { var err error - record, err = s.commitFileMutation(ctx, "write_file", "", "", "written.go", path, []byte("package p\n"), 0o600) + record, err = s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "written.go", path, []byte("package p\n"), 0o600) if err != nil { return nil, err } @@ -250,7 +250,7 @@ func TestEditAcknowledgementActualDiskFailureDoesNotInventAdmission(t *testing.T var record *mutationCommitRecord h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { var err error - record, err = s.commitFileMutation(ctx, "write_file", "", "", "owned-directory", path, []byte("after"), 0o600) + record, err = s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "owned-directory", path, []byte("after"), 0o600) return nil, err }) result, err := h(canonicalTimingContext(), editOperationRequest("write_file", "")) diff --git a/internal/mcp/edit_acknowledgement_tool_boundaries_test.go b/internal/mcp/edit_acknowledgement_tool_boundaries_test.go index 5042a260c..f2d55f9a8 100644 --- a/internal/mcp/edit_acknowledgement_tool_boundaries_test.go +++ b/internal/mcp/edit_acknowledgement_tool_boundaries_test.go @@ -153,7 +153,7 @@ func TestAcknowledgementMoveRealParserAndSourceDurability(t *testing.T) { require.GreaterOrEqual(t, snapshot.ParseGate.Completed, uint64(2), "real source and existing-target Go parser calls") require.NotNil(t, snapshot.ParseGate.AckMS) require.True(t, snapshot.LapsDisjoint) - require.Equal(t, "explicit_go_parser_calls_formatting_unattributed", snapshot.ParserScope) + require.Equal(t, "mixed_observed_parser_paths", snapshot.ParserScope, "explicit Go syntax calls and the committed file projection are both observed") require.Zero(t, snapshot.TicketAdmission.Started, "synchronous move reindex is not a queue admission") } diff --git a/internal/mcp/edit_acknowledgement_wire_test.go b/internal/mcp/edit_acknowledgement_wire_test.go index b5ae8877b..5b4e7e102 100644 --- a/internal/mcp/edit_acknowledgement_wire_test.go +++ b/internal/mcp/edit_acknowledgement_wire_test.go @@ -110,7 +110,7 @@ func TestMutationStatusAcknowledgementSharesRPCAndPreservesPublication(t *testin h := s.boundToolHandler(func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { for _, name := range []string{"a.go", "b.go"} { path := filepath.Join(root, name) - record, err := s.commitFileMutation(ctx, "write_file", "", "", name, path, []byte("package p\n"), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", name, path, []byte("package p\n"), 0o600) if err != nil { return nil, err } diff --git a/internal/mcp/edit_gate_seed_enabled.go b/internal/mcp/edit_gate_seed_enabled.go index 5338c67b0..b3b7a20fa 100644 --- a/internal/mcp/edit_gate_seed_enabled.go +++ b/internal/mcp/edit_gate_seed_enabled.go @@ -16,6 +16,8 @@ func editGateACKSeed(ctx context.Context, s *Server) error { if os.Getenv("GORTEX_EDIT_GATE_SEED") != "ack-wait" { return nil } + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() // The synthetic legacy wait yields its existing dispatch permit, just // like an ordinary publication wait. It owns no writer or extra lap. waiting := make(chan struct{}) diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index 61ccb8650..ca81c376a 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -1,16 +1,20 @@ package mcp import ( + "bytes" "context" "errors" "fmt" + "os" "path/filepath" "sort" "sync" + "sync/atomic" "time" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/viewmetrics" ) @@ -40,12 +44,17 @@ type mutationPathLock struct { // mutation. A receipt identifies admitted publication work that can outlive the // request, either on a watcher or on the selected checkout's coordinator. type mutationReindexOutcome struct { - Reindexed bool - Pending bool - Receipt string - Generation uint64 - AppliedGeneration uint64 - Err error + Reindexed bool + Pending bool + Receipt string + // Same existing receipt ID retained internally when full ACK omits it. + publicationReceipt string + Generation uint64 + AppliedGeneration uint64 + Err error + // tiers is the immutable snapshot from the existing ticket owner. Never + // expose a mutable receipt (and its mutex/history) through an outcome. + tiers indexer.MutationTierState // DerivedFanout is the completeness fact for the bounded derived passes // the reindex ran. Additive and independent of Reindexed: the graph can // have read these bytes while the affected-by re-resolution over them was @@ -128,15 +137,39 @@ type mutationSetScheduler interface { EnqueueFileMutations(context.Context, []string) (map[string]*indexer.MutationTicket, error) } -// acquireMutationPath waits for exclusive mutation access to path. Waiting is -// context-aware: a cancelled MCP request leaves the queue immediately, which -// lets its dispatcher goroutine finish and release admission capacity. -func acquireMutationPath(ctx context.Context, path string) (func(), error) { - if err := guardCheckoutMutationPath(ctx, path); err != nil { - return nil, err +type mutationPathAdmissionKey struct{} + +// A context proves ownership only while the captured lock set is held. The +// release bit is shared with every derived context, so saved contexts cannot +// authorize a later write after their owner returned the token. +type mutationPathAdmission struct { + keys map[string]struct{} + parent *mutationPathAdmission + released atomic.Bool +} + +func mutationPathAdmitted(ctx context.Context, key string) bool { + admission, _ := ctx.Value(mutationPathAdmissionKey{}).(*mutationPathAdmission) + for ; admission != nil; admission = admission.parent { + if !admission.released.Load() { + if _, ok := admission.keys[key]; ok { + return true + } + } } - path = filepath.Clean(path) + return false +} + +// acquireMutationPath waits for exclusive mutation access and returns the +// context proving ownership of that captured path identity until release. +func acquireMutationPath(ctx context.Context, path string) (context.Context, func(), error) { + return acquireMutationPaths(ctx, []string{path}) +} +// acquireMutationPathKey acquires an already-admitted, captured path identity. +// Keeping that key through acquisition also keeps release independent of any +// later symlink change. +func acquireMutationPathKey(ctx context.Context, path string) (func(), error) { mutationPathLocks.Lock() entry := mutationPathLocks.byPath[path] if entry == nil { @@ -171,34 +204,42 @@ func acquireMutationPath(ctx context.Context, path string) (func(), error) { // acquireMutationPaths locks a mutation set in lexical path order. A stable // order prevents deadlock between overlapping batches; cancellation while // waiting releases every previously acquired path in reverse order. -func acquireMutationPaths(ctx context.Context, paths []string) (func(), error) { +func acquireMutationPaths(ctx context.Context, paths []string) (context.Context, func(), error) { unique := make(map[string]struct{}, len(paths)) ordered := make([]string, 0, len(paths)) for _, path := range paths { - clean := filepath.Clean(path) - if _, exists := unique[clean]; exists { + // Admission applies to every spelling, even when two names resolve + // to the same lock. A denied alias must never disappear in dedupe. + if err := guardCheckoutMutationPath(ctx, path); err != nil { + return ctx, nil, err + } + key := pathkey.MutationKey(path) + if _, exists := unique[key]; exists || mutationPathAdmitted(ctx, key) { continue } - unique[clean] = struct{}{} - ordered = append(ordered, clean) + unique[key] = struct{}{} + ordered = append(ordered, key) } sort.Strings(ordered) releases := make([]func(), 0, len(ordered)) - for _, path := range ordered { - release, err := acquireMutationPath(ctx, path) + for _, key := range ordered { + release, err := acquireMutationPathKey(ctx, key) if err != nil { for i := len(releases) - 1; i >= 0; i-- { releases[i]() } - return nil, err + return ctx, nil, err } releases = append(releases, release) } - + parent, _ := ctx.Value(mutationPathAdmissionKey{}).(*mutationPathAdmission) + admission := &mutationPathAdmission{keys: unique, parent: parent} + ctx = context.WithValue(ctx, mutationPathAdmissionKey{}, admission) var once sync.Once - return func() { + return ctx, func() { once.Do(func() { + admission.released.Store(true) for i := len(releases) - 1; i >= 0; i-- { releases[i]() } @@ -220,7 +261,7 @@ func (s *Server) trackMutationTicket(ticket *indexer.MutationTicket) *mutationRe if s.multiIndexer != nil { repo = s.multiIndexer.RepoForFile(ticket.Path) } - return s.trackScopedMutationTicket(ticket, repo, "", "", nil) + return s.trackScopedMutationTicket(ticket, repo, "", "", nil, s.canonicalProjectionRetirement(ticket.Path)) } func (s *Server) trackCheckoutRefreshTicket(ticket *indexer.CheckoutRefreshTicket) *mutationReceipt { @@ -241,7 +282,7 @@ func (s *Server) trackCheckoutRecoveryTicket(ticket *indexer.CheckoutRefreshTick return s.trackScopedMutationTicket(ticket.Ticket, ticket.RepoPrefix, ticket.CheckoutID, ticket.Incarnation, captured) } -func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, checkoutID, incarnation string, recoveryCandidates map[string]struct{}) *mutationReceipt { +func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, checkoutID, incarnation string, recoveryCandidates map[string]struct{}, retirements ...*sourceProjectionRetirement) *mutationReceipt { receipt := &mutationReceipt{ ticket: ticket, id: fmt.Sprintf("mutation-%d", mutationReceiptSequence.Add(1)), @@ -298,6 +339,12 @@ func (s *Server) trackScopedMutationTicket(ticket *indexer.MutationTicket, repo, } s.scheduleMutationReceiptExpiry(receipt, retention) close(receipt.done) + // Read-only content verification is outside ACK completion and the + // repository lane. Canonical backend ownership needs no departed view pin. + // A successful notification merely triggers verification, never proves it. + if result.Err == nil && len(retirements) == 1 { + retirements[0].run() + } }() return receipt } @@ -490,11 +537,15 @@ func (r *mutationReceipt) outcome(pending bool) mutationReindexOutcome { outcome := mutationReindexOutcome{ Pending: pending, Receipt: r.id, + publicationReceipt: r.id, Generation: r.generation, checkoutScoped: r.checkoutScoped, checkoutID: r.checkoutID, checkoutIncarnation: r.checkoutIncarnation, } + if r.ticket != nil { + outcome.tiers = r.ticket.TierState() + } if r.completed { outcome.Reindexed = r.result.Reindexed outcome.AppliedGeneration = r.result.AppliedGeneration @@ -545,85 +596,126 @@ func (s *Server) mutationWaitDuration() time.Duration { return defaultMutationReindexWait } -// mutationReindexState returns the graph-freshness state to expose after a -// successful disk mutation. A live watcher already owns debouncing, patch -// serialization, and latest-bytes reconciliation, so duplicating IndexFile in -// the request path both blocks the response and races the watcher. Embedded -// servers have no watcher and retain the synchronous freshness contract. -func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { +// sourceMutationAdmission separates committing/admitting under the path lock +// from passive graph waiting after the lock has been returned. +type sourceMutationAdmission struct { + policy sourceAckPolicy + receipt *mutationReceipt + immediate mutationReindexOutcome + inlinePath string + checkout *checkoutMutationState + move *moveObservation +} + +// admitSourceMutations captures publication ownership before another writer +// can commit this path. It never waits for a queued watcher publication. +func (s *Server) admitSourceMutations(ctx context.Context, absPath string) (admission sourceMutationAdmission) { + defer func() { admission.policy = s.sourceAckPolicy(ctx) }() if state := checkoutMutationFromContext(ctx); state != nil { - outcome := s.refreshCheckoutMutation(ctx, absPath, state) - if outcome.Err == nil && outcome.Pending && outcome.Receipt != "" { - outcome.Err = editGateCheckoutACKSeed(ctx, s) + if _, queued := state.mutation.(checkoutMutationScheduler); queued { + return sourceMutationAdmission{immediate: s.refreshCheckoutMutation(ctx, absPath, state)} } - return outcome + return sourceMutationAdmission{inlinePath: absPath, checkout: state} } - if watcher := s.currentWatcher(); watcher != nil { - // Admission is path-scoped and authoritative. Scheduling uses a detached - // context because the disk commit already happened; client cancellation - // must not leave the graph permanently stale. - if scheduler, ok := watcher.(mutationScheduler); ok { - // The reindex is detached from this request, so the request's own - // lease cannot be what keeps its payload alive: join it for the - // admitted work and let the receipt hold the pin until the ticket - // reports. Nil whenever no generation stack answered this request. - pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) - publication := s.committedPublicationRecord(ctx, absPath) - admissionLap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) - ticket, scheduleErr := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(context.WithoutCancel(ctx), publication), absPath) - admissionLap.finish() - if scheduleErr != nil { - failMutationPhases(publication) - pin.release() - return mutationReindexOutcome{Err: scheduleErr} - } - if ticket == nil { - failMutationPhases(publication) - // Admission declined to schedule anything, so nothing outlives - // this frame and the fall-through below reindexes inline. - pin.release() - } - if ticket != nil { - receipt := s.trackMutationTicket(ticket) - s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) - receipt.pinView(pin) - // The disk write and ticket admission are complete. This wait - // must not announce another ordinary writer while the watcher - // publishes; actual queued writes still preempt chain folds. - resumeIntent := suspendSourceMutationWriteIntent(ctx) - defer resumeIntent() - // Only waiting from here on: let other requests use this - // dispatch slot. A publication that lands first needs none. - waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) - defer cancel() - waitLap := s.beginAcknowledgementLap(ctx, editAckReindexWait) - if seedErr := editGateACKSeed(ctx, s); seedErr != nil { - waitLap.finish() - outcome := receipt.outcome(true) - outcome.Err = errors.Join(outcome.Err, seedErr) - return outcome - } - if !s.releaseDispatchSlotForWait(waitCtx, receipt.done) { - waitLap.finish() - return receipt.outcome(true) - } - select { - case <-receipt.done: - waitLap.finish() - outcome := receipt.outcome(false) - outcome.Receipt = "" - return outcome - case <-waitCtx.Done(): - waitLap.finish() - return receipt.outcome(true) - } + if scheduler, ok := s.currentWatcher().(mutationScheduler); ok { + pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) + publication := s.committedPublicationRecord(ctx, absPath) + admissionLap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) + ticket, err := scheduler.EnqueueToolMutation(indexer.WithPublicationRecord(context.WithoutCancel(ctx), publication), absPath) + admissionLap.finish() + if err != nil { + failMutationPhases(publication) + pin.release() + return sourceMutationAdmission{immediate: mutationReindexOutcome{Err: err}} + } + if ticket != nil { + receipt := s.trackMutationTicket(ticket) + s.bindReadRepairAdmission(absPath, ticket.PublicationRecord, receipt) + receipt.pinView(pin) + return sourceMutationAdmission{receipt: receipt} + } + failMutationPhases(publication) + pin.release() + } + return sourceMutationAdmission{inlinePath: absPath} +} + +// awaitSourceMutations performs only the admitted publication wait for a live +// watcher. Embedded adapters retain their synchronous refresh after unlocking. +func (s *Server) awaitSourceMutations(ctx context.Context, admission sourceMutationAdmission) mutationReindexOutcome { + if admission.receipt == nil { + if admission.checkout != nil { + return s.refreshCheckoutMutation(ctx, admission.inlinePath, admission.checkout) + } + if admission.inlinePath == "" { + outcome := admission.immediate + if outcome.Err == nil && outcome.Receipt != "" { + outcome.Err = editGateCheckoutACKSeed(ctx, s) } + return outcome + } + if err := ctx.Err(); err != nil { + return mutationReindexOutcome{Err: err} } + retirement := s.canonicalProjectionRetirement(admission.inlinePath) + var reindexed bool + if admission.move != nil { + reindexed = admission.move.reindex(ctx, admission.inlinePath) + } else { + reindexed = s.reindexFile(admission.inlinePath) + } + if reindexed { + retirement.run() + } + return mutationReindexOutcome{Reindexed: reindexed} } - if err := ctx.Err(); err != nil { - return mutationReindexOutcome{Err: err} + receipt := admission.receipt + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + if admission.policy.freshness.requireFresh { + err := s.awaitSourceAckTier(ctx, admission.policy, []string{receipt.path}, receipt.done) + outcome := receipt.outcome(true) + if err != nil { + outcome.Err = err + } + return outcome + } + if admission.policy.local() { + outcome := receipt.outcome(true) + if err := editGateCheckoutACKSeed(ctx, s); err != nil { + outcome.Err = err + } + return outcome + } + waitLap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + if err := editGateACKSeed(ctx, s); err != nil { + waitLap.finish() + outcome := receipt.outcome(true) + outcome.Err = err + return outcome + } + waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) + defer cancel() + if !s.releaseDispatchSlotForWait(waitCtx, receipt.done) { + waitLap.finish() + return receipt.outcome(true) } - return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} + select { + case <-receipt.done: + waitLap.finish() + outcome := receipt.outcome(false) + outcome.Receipt = "" + return outcome + case <-waitCtx.Done(): + waitLap.finish() + return receipt.outcome(true) + } +} + +// Keep the combined entry for callers that have no source path lock. Source +// handlers admit first and explicitly release their locks before awaiting. +func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { + return s.awaitSourceMutations(ctx, s.admitSourceMutations(ctx, absPath)) } // mutationCheckoutScope is metadata-only so receipts remain inspectable while @@ -815,12 +907,16 @@ func (s *Server) mutationReceiptState(id string) (mutationReindexOutcome, bool) // health is only authoritative after completed reindex; reading it while a // watcher patch is pending would surface stale parse errors and provoke an // unnecessary source re-read. -func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath string, outcome mutationReindexOutcome) { +func attachMutationFreshnessFields(resp map[string]any, outcome mutationReindexOutcome) { + for _, key := range []string{"reindex_pending", "reindex_error", "reindex_receipt", "reindex_generation", "applied_generation", "syntax_health", "derived_fanout"} { + delete(resp, key) + } resp["reindexed"] = outcome.Reindexed // graph_status is the freshness half of the mutation contract, named so it // reads next to disk_status (mutation_commit.go) rather than having to be // inferred from the reindexed / reindex_pending / reindex_error triple. resp["graph_status"] = graphStatusFor(outcome) + attachMutationTierState(resp, outcome.tiers) attachDerivedFanout(resp, outcome.DerivedFanout) if outcome.Generation > 0 { resp["reindex_generation"] = outcome.Generation @@ -831,6 +927,10 @@ func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath s if outcome.Receipt != "" { resp["reindex_receipt"] = outcome.Receipt } +} + +func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath string, outcome mutationReindexOutcome) { + attachMutationFreshnessFields(resp, outcome) if outcome.Pending { resp["reindex_pending"] = true return @@ -841,3 +941,37 @@ func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath s } } } + +// plannedSource retains the bytes used to calculate a multi-file rewrite. +// Admission verifies the complete set before the first physical effect. +type plannedSource struct { + path string + before []byte + existed bool +} + +func lockPlannedSources(ctx context.Context, plans []plannedSource) (context.Context, func(), error) { + paths := make([]string, 0, len(plans)) + for _, plan := range plans { + paths = append(paths, plan.path) + } + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return ctx, nil, err + } + for _, plan := range plans { + current, readErr := os.ReadFile(plan.path) + if !plan.existed && errors.Is(readErr, os.ErrNotExist) { + continue + } + if readErr != nil || !plan.existed || !bytes.Equal(current, plan.before) { + release() + return ctx, nil, fmt.Errorf("%w: planned source changed before admission: %s", errMutationNotApplied, plan.path) + } + } + if err := ctx.Err(); err != nil { + release() + return ctx, nil, err + } + return ctx, release, nil +} diff --git a/internal/mcp/edit_serialization_test.go b/internal/mcp/edit_serialization_test.go index 4ebf78a59..1956839f3 100644 --- a/internal/mcp/edit_serialization_test.go +++ b/internal/mcp/edit_serialization_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "errors" + "fmt" "os" "path/filepath" "sync/atomic" @@ -11,6 +12,7 @@ import ( "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/query" ) @@ -66,7 +68,7 @@ type degradedMutationTestWatcher struct { func (w degradedMutationTestWatcher) DegradedReason() string { return w.reason } func mutationRefs(path string) int { - path = filepath.Clean(path) + path = pathkey.MutationKey(path) mutationPathLocks.Lock() defer mutationPathLocks.Unlock() if entry := mutationPathLocks.byPath[path]; entry != nil { @@ -89,7 +91,7 @@ func waitForMutationRefs(t *testing.T, path string, want int) { func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { path := filepath.Join(t.TempDir(), "same.go") - firstRelease, err := acquireMutationPath(context.Background(), path) + _, firstRelease, err := acquireMutationPath(context.Background(), path) if err != nil { t.Fatalf("first acquire: %v", err) } @@ -97,7 +99,7 @@ func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { acquired := make(chan func(), 1) errs := make(chan error, 1) go func() { - release, acquireErr := acquireMutationPath(context.Background(), path) + _, release, acquireErr := acquireMutationPath(context.Background(), path) if acquireErr != nil { errs <- acquireErr return @@ -126,7 +128,7 @@ func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { func TestAcquireMutationPathCancellationReleasesQueueReference(t *testing.T) { path := filepath.Join(t.TempDir(), "cancel.go") - ownerRelease, err := acquireMutationPath(context.Background(), path) + _, ownerRelease, err := acquireMutationPath(context.Background(), path) if err != nil { t.Fatalf("owner acquire: %v", err) } @@ -134,7 +136,7 @@ func TestAcquireMutationPathCancellationReleasesQueueReference(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) errs := make(chan error, 1) go func() { - _, acquireErr := acquireMutationPath(ctx, path) + _, _, acquireErr := acquireMutationPath(ctx, path) errs <- acquireErr }() waitForMutationRefs(t, path, 2) @@ -159,7 +161,7 @@ func TestAcquireMutationPathsCancellationReleasesEarlierLocks(t *testing.T) { first, second = second, first } - releaseSecond, err := acquireMutationPath(context.Background(), second) + _, releaseSecond, err := acquireMutationPath(context.Background(), second) if err != nil { t.Fatal(err) } @@ -168,7 +170,7 @@ func TestAcquireMutationPathsCancellationReleasesEarlierLocks(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { - release, lockErr := acquireMutationPaths(ctx, []string{second, first, first}) + _, release, lockErr := acquireMutationPaths(ctx, []string{second, first, first}) if release != nil { release() } @@ -288,23 +290,28 @@ func TestAttachMutationFreshnessPendingOmitsStaleSyntaxHealth(t *testing.T) { } } -func TestApplyBatchFileEditSerializesConcurrentWrites(t *testing.T) { +func TestAtomicBatchSerializesConcurrentFileEdits(t *testing.T) { path := filepath.Join(t.TempDir(), "concurrent.txt") if err := os.WriteFile(path, []byte("alpha beta\n"), 0o644); err != nil { t.Fatal(err) } - s := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) start := make(chan struct{}) results := make(chan batchEditResult, 2) - for _, edit := range []batchEditItem{ - {Path: path, OldString: "alpha", NewString: "ALPHA"}, - {Path: path, OldString: "beta", NewString: "BETA"}, + for i, edit := range []batchEditItem{ + {Op: "edit_file", Path: path, OldString: "alpha", NewString: "ALPHA"}, + {Op: "edit_file", Path: path, OldString: "beta", NewString: "BETA"}, } { edit := edit go func() { <-start - results <- s.applyBatchFileEdit(context.Background(), edit, true) + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{edit}, fmt.Sprintf("concurrent-file-%d", i)) + if err != nil { + results <- batchEditResult{Status: "failed", Error: err.Error()} + return + } + results <- receipt.Results[0] }() } close(start) @@ -323,7 +330,7 @@ func TestApplyBatchFileEditSerializesConcurrentWrites(t *testing.T) { } } -func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { +func TestAtomicBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { path := filepath.Join(t.TempDir(), "shifted.go") content := "package sample\n\nfunc target() {\n\tprintln(\"old\")\n}\n\nfunc other() {}\n" if err := os.WriteFile(path, []byte(content), 0o644); err != nil { @@ -340,17 +347,18 @@ func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { EndLine: 7, } g.AddNode(node) - s := &Server{ - engine: query.NewEngine(g), - graph: g, - watcher: mutationTestWatcher{}, - session: newSessionState(), - } - result := s.applyBatchSymbolEdit(context.Background(), batchEditItem{ + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + s.engine, s.graph = query.NewEngine(g), g + receipt, runErr := s.runBatchTransaction(context.Background(), []batchEditItem{{ + Op: "edit_symbol", SymbolID: node.ID, OldSource: "println(\"old\")", NewSource: "println(\"new\")", - }, true) + }}, "shifted-symbol") + if runErr != nil { + t.Fatal(runErr) + } + result := receipt.Results[0] if result.Status != "applied" || !result.Reindexed || result.ReindexPending || result.ReindexError != "" { t.Fatalf("unexpected result: %+v", result) } diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 146cece39..77db98f83 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -1077,7 +1077,14 @@ func attachReceiptTiers(payload map[string]any, receipt *mutationReceipt) { if receipt == nil || receipt.ticket == nil { return } - tiers := receipt.ticket.TierState() + attachMutationTierState(payload, receipt.ticket.TierState()) +} + +// Mutation status and source ACKs render the same immutable producer snapshot. +func attachMutationTierState(payload map[string]any, tiers indexer.MutationTierState) { + for _, key := range []string{"tier_state", "ack_at", "file_graph_at", "relations_at", "derived_at"} { + delete(payload, key) + } if tiers.AckAt.IsZero() { return } @@ -1112,3 +1119,20 @@ func shortFreshSHA(sha string) string { } return sha } + +// File exactness is physical projection evidence, independent of the ticket's +// graph/relations/derived producer. Copy the map so cached ACK snapshots remain +// immutable when a later replay observes a replacement or invalidation. +func attachMutationFileTier(payload map[string]any, exact bool) { + states := make(map[string]string, 4) + if previous, ok := payload["tier_state"].(map[string]string); ok { + for key, value := range previous { + states[key] = value + } + } + states["file"] = "pending" + if exact { + states["file"] = "fresh" + } + payload["tier_state"] = states +} diff --git a/internal/mcp/move_file_observation.go b/internal/mcp/move_file_observation.go index 675241a1c..d8fe83a0c 100644 --- a/internal/mcp/move_file_observation.go +++ b/internal/mcp/move_file_observation.go @@ -19,11 +19,14 @@ type moveFileObservation struct { operationID, sourceID, targetID, role string moveSession, moveRequestKey string writeVerdict, directState string + backgroundState string + backgroundWait bool directClaimed bool readClaimed bool readState string readAssisted *moveDirectObservation direct *moveDirectObservation + physical weak.Pointer[mutationCommitRecord] } type moveDirectObservation struct { generation uint64 @@ -66,43 +69,67 @@ func (s *Server) beginMoveObservation(ctx context.Context, sourceID, targetID, s return out } -func (o *moveObservation) write(ack *editAcknowledgement, path string, content []byte) error { +func (o *moveObservation) write(ctx context.Context, s *Server, path string, content []byte) (*mutationCommitRecord, error) { + physical, err := s.writeMoveFile(ctx, "move_symbol", path, content) if o == nil { - return writeMoveFileObserved(ack, path, content) + return physical, err } - started := time.Now() - err := writeMoveFileObserved(ack, path, content) - returned := time.Now() record := o.records[path] if record == nil { - return err + return physical, err } - blob := "" - if err == nil { - blob = gitBlobSHA(content) - } // no existing fingerprint is produced by the move writer ledger := &o.server.mutationCommits ledger.mu.Lock() defer ledger.mu.Unlock() + if physical != nil { + physical.mu.Lock() + defer physical.mu.Unlock() + } record.mu.Lock() defer record.mu.Unlock() - if ledger.byID[record.id] != record || record.owner != o.server || record.kind != moveFileObservationCommit || record.moveFile == nil || record.key != o.id { - return err + if ledger.byID[record.id] != record || record.owner != s || record.kind != moveFileObservationCommit || record.moveFile == nil || record.key != o.id { + return physical, err } if !record.diskWriteStartedAt.IsZero() { - record.disk = mutationDiskFailed - record.errText = "unavailable_repeated_writer" - return err + record.disk, record.errText = mutationDiskFailed, "unavailable_repeated_writer" + return physical, err } - record.diskWriteStartedAt, record.committedAt = started, returned - if err != nil { + if err != nil || physical == nil { record.disk = mutationDiskFailed - record.errText = err.Error() - return err + if err != nil { + record.errText = err.Error() + } + return physical, err + } + if ledger.byID[physical.id] != physical || physical.owner != s || physical.kind != physicalMutationCommit || physical.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != path || physical.disk != mutationDiskCommitted { + record.disk, record.errText = mutationDiskFailed, "unavailable_physical_owner" + return physical, err + } + // This is one write event. Borrow its actual physical timestamps and + // sequence; no second disk lap, commit sequence or source authority. + record.diskWriteStartedAt, record.committedAt = physical.diskWriteStartedAt, physical.committedAt + record.disk, record.newSHA, record.bytesWritten, record.commitSeq = physical.disk, physical.newSHA, physical.bytesWritten, physical.commitSeq + record.moveFile.physical = weak.Make(physical) + physical.moveTimingOwner = weak.Make(record) + return physical, err +} + +// The caller holds ledger and the timing record. A weak association grants +// no lifetime: eviction, a foreign ACK/path/owner or a replaced link refuses. +func linkedMovePhysicalLocked(ledger *mutationCommitLedger, s *Server, record *mutationCommitRecord) *mutationCommitRecord { + if record.moveFile == nil { + return nil + } + physical := record.moveFile.physical.Value() + if physical == nil || physical == record { + return nil + } + physical.mu.RLock() + defer physical.mu.RUnlock() + if ledger.byID[record.id] != record || ledger.byID[physical.id] != physical || record.owner != s || physical.owner != s || record.kind != moveFileObservationCommit || physical.kind != physicalMutationCommit || physical.moveTimingOwner.Value() != record || record.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != record.absPath || physical.disk != mutationDiskCommitted || physical.commitSeq == 0 || physical.commitSeq != record.commitSeq || physical.newSHA == "" || physical.newSHA != record.newSHA || !physical.committedAt.Equal(record.committedAt) || !physical.diskWriteStartedAt.Equal(record.diskWriteStartedAt) { + return nil } - record.disk, record.newSHA, record.bytesWritten = mutationDiskCommitted, blob, len(content) - record.commitSeq = mutationDiskCommitSequence.Add(1) - return err // observation never changes the real writer outcome + return physical } func (o *moveObservation) finishWrites(success bool) { @@ -158,6 +185,7 @@ func (o *moveObservation) observer(ctx context.Context, path string, idx *indexe ledger.mu.Lock() record.mu.Lock() valid := ledger.byID[record.id] == record && record.kind == moveFileObservationCommit && record.owner == o.server && record.moveFile != nil && record.moveFile.writeVerdict == "all_writes_completed" && !record.moveFile.directClaimed && record.disk == mutationDiskCommitted && record.newSHA != "" && !record.committedAt.IsZero() && record.commitSeq != 0 + valid = valid && linkedMovePhysicalLocked(ledger, o.server, record) != nil if valid { witness.id, witness.operation, witness.path, witness.blob, witness.committed, witness.commitSeq = record.id, record.key, record.absPath, record.newSHA, record.committedAt, record.commitSeq record.moveFile.directClaimed = true @@ -186,14 +214,23 @@ func (s *Server) moveReadRepairObserver(ctx context.Context, path string, idx *i if !record.isPublicationOwner() && record.kind != moveFileObservationCommit || record.absPath != path { continue } + physical := record if record.kind != moveFileObservationCommit { - return nil, false + record.mu.RLock() + paired := record.moveTimingOwner.Value() + record.mu.RUnlock() + if paired == nil { + return nil, false + } + record = paired } record.mu.Lock() defer record.mu.Unlock() - if ledger.byID[record.id] != record || record.owner != s || record.moveFile == nil || record.moveFile.readClaimed || record.disk != mutationDiskCommitted || record.newSHA == "" || record.committedAt.IsZero() || record.commitSeq == 0 { + linked := linkedMovePhysicalLocked(ledger, s, record) + if linked == nil || physical != record && linked != physical || ledger.byID[record.id] != record || record.owner != s || record.moveFile == nil || record.moveFile.readClaimed || record.disk != mutationDiskCommitted || record.newSHA == "" || record.committedAt.IsZero() || record.commitSeq == 0 { return nil, true } + // Capture can precede the overall write verdict. Completion still requires // that exact record's final successful verdict; no later retry can rebind it. if record.moveFile.writeVerdict != "pending" && record.moveFile.writeVerdict != "all_writes_completed" { @@ -269,10 +306,19 @@ func (w moveDirectWitness) complete(observed indexer.ReadRepairObservation) { locked[i].mu.RUnlock() } }() + record.mu.RLock() + ownedPhysical := linkedMovePhysicalLocked(ledger, s, record) + record.mu.RUnlock() firstPath := true refused := "" + if ownedPhysical == nil { + refused = "unavailable_physical_owner" + } for i := len(ledger.ordered) - 1; i >= 0; i-- { candidate := ledger.ordered[i] + if candidate == ownedPhysical { + continue + } // same actual write, already checked above if !candidate.isPublicationOwner() && candidate.kind != moveFileObservationCommit || candidate.absPath != w.path { continue } @@ -378,6 +424,45 @@ func (o *moveObservation) noteDirectUnavailable(path, reason string) { } } +// Admission labels describe the route actually taken. They never manufacture +// a direct publication interval or confer physical/freshness authority. +func (o *moveObservation) observeAdmission(path string, admission *sourceMutationAdmission) { + if o == nil { + return + } + if admission.inlinePath != "" { + if admission.checkout == nil { + admission.move = o + } else { + o.noteDirectUnavailable(path, "unavailable_checkout_refresh_observer") + } + return + } + state := "not_observed" + if admission.receipt != nil || admission.immediate.Receipt != "" { + state = "admitted" + } else if admission.immediate.Err != nil { + state = "admission_failed" + } + record := o.records[path] + if record == nil { + return + } + ledger := &o.server.mutationCommits + ledger.mu.Lock() + defer ledger.mu.Unlock() + record.mu.Lock() + defer record.mu.Unlock() + if ledger.byID[record.id] != record || record.moveFile == nil { + return + } + data := record.moveFile + data.backgroundState = state + data.backgroundWait = admission.receipt != nil && !admission.policy.local() + data.directClaimed, data.readClaimed = true, true + data.directState, data.readState = "unavailable_queued_publication", "unavailable_queued_publication" +} + func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map[string]any, bool) { if !strings.HasPrefix(id, "move-file-") { return nil, false @@ -398,10 +483,21 @@ func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map payload["direct_reconcile_state"], payload["requested_background_state"] = data.directState, "not_applicable_direct_reconcile" payload["read_assisted_reconcile_state"] = data.readState payload["move_request_cause"] = map[string]string{"session": data.moveSession, "request_id_key": data.moveRequestKey} - // This handler calls the existing synchronous reconcile, never queue - // admission/wait. Preserve raw ACK nulls/counts and name applicability - // explicitly instead of manufacturing measured zero scheduler work. - payload["acknowledgement_phase_applicability"] = map[string]string{"publish": "not_applicable_direct_reconcile", "reindex_wait": "not_applicable_direct_reconcile"} + applicability := map[string]string{"publish": "not_applicable_direct_reconcile", "reindex_wait": "not_applicable_direct_reconcile"} + if data.backgroundState != "" { + payload["requested_background_state"] = data.backgroundState + applicability["publish"], applicability["reindex_wait"] = "not_observed", "not_observed" + if data.backgroundState == "admitted" || data.backgroundState == "admission_failed" { + applicability["publish"] = "applicable" + } + if data.backgroundState == "admitted" { + applicability["reindex_wait"] = "not_applicable_fast_ack" + if data.backgroundWait { + applicability["reindex_wait"] = "applicable" + } + } + } + payload["acknowledgement_phase_applicability"] = applicability timing := map[string]any{"scope": "move_file_write", "clock": "monotonic", "state": "not_observed", "outcome": record.disk} if !record.diskWriteStartedAt.IsZero() && !record.committedAt.IsZero() { timing["state"] = "invalid_order" diff --git a/internal/mcp/move_file_observation_test.go b/internal/mcp/move_file_observation_test.go index 0b1afb76d..29e3436a3 100644 --- a/internal/mcp/move_file_observation_test.go +++ b/internal/mcp/move_file_observation_test.go @@ -12,6 +12,7 @@ import ( "sync" "testing" "time" + "weak" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -131,8 +132,8 @@ func moveObservationExecutionFixture(t *testing.T) (*Server, *moveObservation, c t.Cleanup(ack.finishActual) observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) require.NotNil(t, observation) - require.NoError(t, observation.write(ack, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, observation.write(ack, source, []byte("package main\nfunc After() { Existing() }\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\nfunc Moved() {}\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, source, []byte("package main\nfunc After() { Existing() }\n"))) observation.finishWrites(true) return s, observation, ctx, ack, source, target } @@ -159,38 +160,52 @@ func captureMoveActualExecution(t *testing.T, s *Server, path string, observer * } } -func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *testing.T) { +func TestMoveWaitsForOlderHeldPhysicalWrite(t *testing.T) { s, _ := setupContractMultiRepoServer(t, "fixture") t.Cleanup(func() { s.DrainBackground(); require.NoError(t, s.multiIndexer.Close(context.Background())) }) root, ok := s.multiIndexer.RepoRoot("fixture") require.True(t, ok) source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") bytes := []byte("package main\nfunc After() { Existing() }\n") - require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0600)) - require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0600)) + require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0o600)) + require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0o600)) _, err := s.multiIndexer.IncrementalReindexRepo("fixture", []string{source, target}) require.NoError(t, err) entered, release := make(chan *mutationCommitRecord, 1), make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } - s.mutationPreCommitHook = func(record *mutationCommitRecord) { entered <- record; <-release } + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.key == "older-admission" { + entered <- record + <-release + } + } type result struct { record *mutationCommitRecord err error } done := make(chan result, 1) - joined := false go func() { - record, err := s.commitFileMutation(t.Context(), "edit_file", "older-admission", "same-bytes", "fixture/source.go", source, bytes, 0600) + record, err := s.commitHeldFileMutationForTest(t, t.Context(), "edit_file", "older-admission", "same-bytes", "fixture/source.go", source, bytes, 0o600) done <- result{record, err} }() + joined := false + var moveDone chan error + moveJoined := false t.Cleanup(func() { unblock() if !joined { select { case <-done: case <-time.After(5 * time.Second): - t.Error("competing real writer was not joined") + t.Error("older writer did not join") + } + } + if moveDone != nil && !moveJoined { + select { + case <-moveDone: + case <-time.After(5 * time.Second): + t.Error("move writer did not join") } } }) @@ -198,36 +213,66 @@ func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *te select { case older = <-entered: case <-time.After(5 * time.Second): - t.Fatal("older writer not admitted") + t.Fatal("older writer was not admitted") } ctx, ack, _ := s.withEditAcknowledgement(t.Context()) defer ack.finishActual() observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) - require.NoError(t, observation.write(ack, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, observation.write(ack, source, bytes)) - observation.finishWrites(true) - idx := s.multiIndexer.GetIndexer("fixture") - observer := observation.observer(ctx, source, idx) - require.NotNil(t, observer) - unblock() - var finished result + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\nfunc Moved() {}\n"))) + moveDone = make(chan error, 1) + attempted := make(chan struct{}) + go func() { close(attempted); moveDone <- writeObservedMoveForTest(t, observation, ack, source, bytes) }() + <-attempted select { - case finished = <-done: - joined = true - case <-time.After(5 * time.Second): - t.Fatal("older actual writer did not return") + case err := <-moveDone: + moveJoined = true + t.Fatalf("move overtook a held source writer: %v", err) + case <-time.After(25 * time.Millisecond): } + unblock() + finished := <-done + joined = true require.NoError(t, finished.err) require.Same(t, older, finished.record) + require.NoError(t, <-moveDone) + moveJoined = true + observation.finishWrites(true) record := observation.records[source] - require.Greater(t, older.commitSeq, record.commitSeq, "guard must exercise actual commit order opposite admission order") + require.Greater(t, record.commitSeq, older.commitSeq, "the later source write must follow actual lock release") + idx := s.multiIndexer.GetIndexer("fixture") + observer := observation.observer(ctx, source, idx) + require.NotNil(t, observer) + actual := captureMoveActualExecution(t, s, source, observer) + require.True(t, actual.AcceptedFor(idx, gitBlobSHA(bytes))) + status, _ := s.moveFileObservationPayload(t.Context(), record.id) + require.Equal(t, "observed", status["direct_reconcile_state"]) +} + +func TestMoveDirectRejectsModeledLegacyReorderedCommit(t *testing.T) { + s, move, ctx, _, source, _ := moveObservationExecutionFixture(t) + record := move.records[source] + idx := s.multiIndexer.GetIndexer("fixture") + observer := move.observer(ctx, source, idx) + require.NotNil(t, observer) + // The old unguarded admission/commit inversion is now unreachable through + // source writers. Model only its ledger evidence to retain the defensive + // compatibility fence; this is not an actual-write/performance claim. + older := s.beginMutationCommit(t.Context(), "edit_file", "modeled-legacy", "same-bytes", "fixture/source.go", source) + older.mu.Lock() + older.startedAt = record.startedAt.Add(-time.Nanosecond) + older.mu.Unlock() + s.mutationCommits.mu.Lock() + ordered := s.mutationCommits.ordered + copy(ordered[1:], ordered[:len(ordered)-1]) + ordered[0] = older + s.mutationCommits.mu.Unlock() + older.markCommitted(record.newSHA, record.bytesWritten) + require.Greater(t, older.commitSeq, record.commitSeq) actual := captureMoveActualExecution(t, s, source, observer) - require.True(t, actual.AcceptedFor(idx, gitBlobSHA(bytes)), "wrong-stage rejection would not test competing commit fence") + require.True(t, actual.AcceptedFor(idx, record.newSHA)) status, _ := s.moveFileObservationPayload(t.Context(), record.id) require.Equal(t, "unavailable_competing_commit", status["direct_reconcile_state"]) require.NotContains(t, status, "direct_reconcile") - // A repeated callback must not revise the first final refusal even when - // competing evidence has since changed from committed to in-flight. older.mu.Lock() older.disk = mutationDiskInFlight older.diskWriteStartedAt = actual.AcceptedAt() @@ -235,7 +280,6 @@ func TestMoveDirectRefusesOlderAdmissionWhichActuallyCommitsLaterSameBytes(t *te observer.Complete(actual) repeated, _ := s.moveFileObservationPayload(t.Context(), record.id) require.Equal(t, "unavailable_competing_commit", repeated["direct_reconcile_state"]) - require.NotContains(t, repeated, "direct_reconcile") } func TestMoveDirectRejectsActualEarlyAcceptanceForeignOwnerBytesAndEviction(t *testing.T) { @@ -251,15 +295,15 @@ func TestMoveDirectRejectsActualEarlyAcceptanceForeignOwnerBytesAndEviction(t *t require.NotNil(t, observer) record := observation.records[source] if kind == "foreign_bytes" { - require.NoError(t, writeMoveFileObserved(nil, source, []byte("package main\nfunc OtherBytes() { Existing() }\n"))) + require.NoError(t, writeMoveFileForTest(t, s, nil, source, []byte("package main\nfunc OtherBytes() { Existing() }\n"))) } actual := captureMoveActualExecution(t, s, source, nil) if kind == "early_acceptance" { ctx2, ack2, _ := s.withEditAcknowledgement(t.Context()) defer ack2.finishActual() second := s.beginMoveObservation(ctx2, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) - require.NoError(t, second.write(ack2, target, []byte("package main\nfunc Moved() {}\n"))) - require.NoError(t, second.write(ack2, source, []byte("package main\nfunc After() { Existing() }\n"))) + require.NoError(t, writeObservedMoveForTest(t, second, ack2, target, []byte("package main\nfunc Moved() {}\n"))) + require.NoError(t, writeObservedMoveForTest(t, second, ack2, source, []byte("package main\nfunc After() { Existing() }\n"))) second.finishWrites(true) observation, record = second, second.records[source] observer = second.observer(ctx2, source, idx) @@ -295,10 +339,10 @@ func TestMoveFileWriteWindowsPartialFailureAndUnobservedIsolation(t *testing.T) s, _, ctx, ack, source, target := moveObservationExecutionFixture(t) observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) targetBytes := []byte("package main\nfunc ActuallyWritten() {}\n") - require.NoError(t, observation.write(ack, target, targetBytes)) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, targetBytes)) require.NoError(t, os.Remove(source)) require.NoError(t, os.Mkdir(source, 0700)) - require.Error(t, observation.write(ack, source, []byte("package main\nfunc CannotReplaceDirectory() {}\n"))) + require.Error(t, writeObservedMoveForTest(t, observation, ack, source, []byte("package main\nfunc CannotReplaceDirectory() {}\n"))) observation.finishWrites(false) targetStatus, _ := s.moveFileObservationPayload(t.Context(), observation.records[target].id) sourceStatus, _ := s.moveFileObservationPayload(t.Context(), observation.records[source].id) @@ -386,7 +430,7 @@ func TestMoveFileWriteTimingPreservesEqualClockReadings(t *testing.T) { source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") observation := s.beginMoveObservation(ctx, "fixture/source.go::Before", "fixture/target.go::Before", source, target, "fixture/source.go", "fixture/target.go", true) require.NotNil(t, observation) - require.NoError(t, observation.write(ack, target, []byte("package main\n"))) + require.NoError(t, writeObservedMoveForTest(t, observation, ack, target, []byte("package main\n"))) record := observation.records[target] // Model clock resolution at the report boundary after a real write. // No producer value is rounded up or replaced by a timing sleep. @@ -412,3 +456,71 @@ func TestMoveFileWriteTimingPreservesEqualClockReadings(t *testing.T) { }) } } + +// Fixture writes acquire real admission too; production callers retain their +// complete CAS set instead of reacquiring a path around individual effects. +func writeObservedMoveForTest(t *testing.T, observation *moveObservation, ack *editAcknowledgement, path string, content []byte) error { + t.Helper() + ctx := context.WithValue(t.Context(), editAcknowledgementKey{}, ack) + ctx, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err + } + defer release() + _, err = observation.write(ctx, observation.server, path, content) + return err +} + +func writeMoveFileForTest(t *testing.T, s *Server, ack *editAcknowledgement, path string, content []byte) error { + t.Helper() + ctx := context.WithValue(t.Context(), editAcknowledgementKey{}, ack) + ctx, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err + } + defer release() + _, err = s.writeMoveFile(ctx, "move_symbol", path, content) + return err +} + +func TestMoveTimingRequiresItsExactRetainedPhysicalEvent(t *testing.T) { + for _, kind := range []string{"owned", "different_ack", "foreign_server", "expired_physical", "reset_link", "wrong_path"} { + t.Run(kind, func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + s.mutationCommits.mu.Lock() + timing.mu.Lock() + physical.mu.Lock() + switch kind { + case "different_ack": + physical.acknowledgement = &editAcknowledgement{} + case "foreign_server": + physical.owner = &Server{} + case "expired_physical": + delete(s.mutationCommits.byID, physical.id) + case "reset_link": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "wrong_path": + physical.absPath = target + ".foreign" + } + physical.mu.Unlock() + timing.mu.Unlock() + s.mutationCommits.mu.Unlock() + idx := s.multiIndexer.GetIndexer("fixture") + direct := move.observer(ctx, target, idx) + read, _ := s.moveReadRepairObserver(ctx, target, idx) + if kind == "owned" { + require.NotNil(t, direct) + require.NotNil(t, read) + require.Equal(t, physical.commitSeq, timing.commitSeq) + require.Equal(t, physical.diskWriteStartedAt, timing.diskWriteStartedAt) + require.Equal(t, physical.committedAt, timing.committedAt) + } else { + require.Nil(t, direct) + require.Nil(t, read) + } + }) + } +} diff --git a/internal/mcp/move_read_assisted_observation_test.go b/internal/mcp/move_read_assisted_observation_test.go index 3a7dd9491..7ebbba2e9 100644 --- a/internal/mcp/move_read_assisted_observation_test.go +++ b/internal/mcp/move_read_assisted_observation_test.go @@ -52,7 +52,7 @@ func TestMoveReadAssistedRejectsNewerPublicationOwners(t *testing.T) { // owner has the actual later commit order for each supported owner kind. bytes, err := os.ReadFile(target) require.NoError(t, err) - require.NoError(t, writeMoveFileObserved(nil, target, bytes)) + require.NoError(t, writeMoveFileForTest(t, s, nil, target, bytes)) foreign := &mutationCommitRecord{kind: kind, owner: s, id: "foreign-newer", absPath: target, disk: mutationDiskCommitted, newSHA: gitBlobSHA(bytes), committedAt: time.Now(), commitSeq: mutationDiskCommitSequence.Add(1)} s.mutationCommits.put(foreign) actual := captureMoveActualExecution(t, s, target, observer) diff --git a/internal/mcp/mutation_commit.go b/internal/mcp/mutation_commit.go index 10912b5bb..19dfc808c 100644 --- a/internal/mcp/mutation_commit.go +++ b/internal/mcp/mutation_commit.go @@ -15,8 +15,11 @@ import ( "time" "weak" + "github.com/zeebo/blake3" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" + "github.com/zzet/gortex/internal/platform" ) // The commit ledger exists because a tool call has two independent terminal @@ -117,6 +120,7 @@ type mutationCommitRecord struct { pendingSourceRecovered bool id string + ackMode string tool string key string relPath string @@ -138,8 +142,10 @@ type mutationCommitRecord struct { // terminality is the opposite in the two cases. graphRecorded bool + contentSHA256 string newSHA string bytesWritten int + publicationReceipt string reindexReceipt string reindexGeneration uint64 appliedGeneration uint64 @@ -167,6 +173,10 @@ type mutationCommitRecord struct { batchOwner [32]byte batchAfterSHA256 [32]byte moveFile *moveFileObservation + moveTimingOwner weak.Pointer[mutationCommitRecord] + fileProjection weak.Pointer[projectionEntry] + fileReason string + readbackExact bool // response is the successful tool payload, retained only for records that // carry a caller-chosen mutation_id. Without a key there is nothing to @@ -175,6 +185,7 @@ type mutationCommitRecord struct { } type mutationCommitSnapshot struct { + AckMode string `json:"ack_mode,omitempty"` Receipt string `json:"receipt"` Tool string `json:"tool,omitempty"` MutationID string `json:"mutation_id,omitempty"` @@ -187,6 +198,7 @@ type mutationCommitSnapshot struct { // snapshot the flag is derived from, so the two cannot disagree. GraphRecorded bool `json:"-"` CommitSeq uint64 `json:"-"` + ContentSHA256 string `json:"content_sha256,omitempty"` NewSHA string `json:"new_sha,omitempty"` BytesWritten int `json:"bytes_written,omitempty"` Error string `json:"error,omitempty"` @@ -198,6 +210,7 @@ type mutationCommitSnapshot struct { AppliedGeneration uint64 `json:"applied_generation,omitempty"` CheckoutID string `json:"checkout_id,omitempty"` CheckoutIncarnation string `json:"checkout_incarnation,omitempty"` + publicationReceipt string readRepair *mutationReadRepairObservation } @@ -208,6 +221,7 @@ func (r *mutationCommitRecord) snapshot() mutationCommitSnapshot { r.mu.RLock() defer r.mu.RUnlock() snap := mutationCommitSnapshot{ + AckMode: r.ackMode, Receipt: r.id, Tool: r.tool, MutationID: r.key, @@ -216,11 +230,13 @@ func (r *mutationCommitRecord) snapshot() mutationCommitSnapshot { GraphStatus: r.graph, GraphRecorded: r.graphRecorded, CommitSeq: r.commitSeq, + ContentSHA256: r.contentSHA256, NewSHA: r.newSHA, BytesWritten: r.bytesWritten, Error: r.errText, Reason: r.graphReason, ReindexReceipt: r.reindexReceipt, + publicationReceipt: r.publicationReceipt, ReindexGeneration: r.reindexGeneration, AppliedGeneration: r.appliedGeneration, CheckoutID: r.checkoutID, @@ -287,6 +303,10 @@ func (r *mutationCommitRecord) markFailed(err error) { } func (r *mutationCommitRecord) markCommitted(newSHA string, bytesWritten int) { + r.markCommittedContent(newSHA, bytesWritten, "") +} + +func (r *mutationCommitRecord) markCommittedContent(newSHA string, bytesWritten int, contentSHA256 string) { if !r.isPhysicalCommit() { return } @@ -296,6 +316,7 @@ func (r *mutationCommitRecord) markCommitted(newSHA string, bytesWritten int) { if r.commitSeq == 0 { r.commitSeq = mutationDiskCommitSequence.Add(1) } + r.contentSHA256 = contentSHA256 r.newSHA = newSHA r.bytesWritten = bytesWritten r.committedAt = time.Now() @@ -370,6 +391,14 @@ func (r *mutationCommitRecord) recordGraphForReceipt(expectedReceipt string, out ledger.byReindexReceipt[outcome.Receipt] = r } } + r.publicationReceipt = "" + if r.owner != nil && outcome.publicationReceipt != "" { + if value, found := r.owner.mutationReceipts.Load(outcome.publicationReceipt); found { + if receipt, ok := value.(*mutationReceipt); ok && receipt.path == r.absPath && receipt.generation == outcome.Generation && receipt.checkoutID == r.checkoutID && receipt.checkoutIncarnation == r.checkoutIncarnation { + r.publicationReceipt = outcome.publicationReceipt + } + } + } r.reindexReceipt = outcome.Receipt r.reindexGeneration = outcome.Generation r.appliedGeneration = outcome.AppliedGeneration @@ -427,6 +456,9 @@ func (r *mutationCommitRecord) retainResponse(resp map[string]any) { } clone := make(map[string]any, len(resp)+1) for key, value := range resp { + if key == "file_view" { + continue + } // the bounded registry alone retains delta strings clone[key] = value } r.response = clone @@ -700,6 +732,7 @@ func (n *mutationCommitNote) verdict() mutationCommitVerdict { func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fingerprint, relPath, absPath string) *mutationCommitRecord { record := &mutationCommitRecord{ owner: s, + ackMode: string(s.sourceAckPolicy(ctx).mode), id: fmt.Sprintf("commit-%d", mutationCommitSequence.Add(1)), tool: tool, key: mutationID, @@ -723,6 +756,28 @@ func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fing return record } +type sourceMutationOperationKind uint8 + +const sourceWrite sourceMutationOperationKind = 0 + +const ( + batchForwardWrite sourceMutationOperationKind = iota + 1 + batchForwardRemove + batchRestoreWrite + batchRestoreRemove + sourceRemove + sourceCohortWrite +) + +// This private descriptor selects ordinary prepared source I/O or a durable +// batch's existing terminal authority. Journal/backup metadata never enters +// this boundary. Batch observation belongs exclusively to forward I/O. +type sourceMutationOperation struct { + kind sourceMutationOperationKind + observation *mutationCommitRecord + projection *preparedFileProjection +} + // commitFileMutation is the single guarded disk-commit primitive for the // single-file mutating tools. Every caller gets three things it did not have // before: a refusal when the request is already dead, a durable receipt, and a @@ -732,7 +787,49 @@ func (s *Server) commitFileMutation( tool, mutationID, fingerprint, relPath, absPath string, data []byte, perm os.FileMode, + operations ...sourceMutationOperation, ) (*mutationCommitRecord, error) { + remove := len(operations) == 1 && operations[0].kind == sourceRemove + ordinarySource := len(operations) == 0 || len(operations) == 1 && (remove || operations[0].kind == sourceWrite || operations[0].kind == sourceCohortWrite) + if !ordinarySource { + // Branch before beginMutationCommit: it registers ordinary source + // authority even before markCommitted. The durable transaction is the + // sole authority for forward effects and restoration alike. + if len(operations) != 1 { + return nil, fmt.Errorf("%w: invalid source operation", errMutationNotApplied) + } + operation := operations[0] + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return nil, fmt.Errorf("%w: batch source path is not held for mutation", errMutationNotApplied) + } + if err := guardCheckoutMutationPath(ctx, absPath); err != nil { + return nil, err + } + // No cancellation or per-file Prepare here: the coordinator has crossed + // its existing commit boundary and must reach a durable terminal state. + ops := s.batchDurability() + switch operation.kind { + case batchForwardWrite: + writer := ops.writeFile + if s.batchWriteOverride != nil { + writer = s.batchWriteOverride + } + writer = s.observedBatchFileWriter(operation.observation, writer) + return nil, s.writeAcknowledgedBatchFile(ctx, writer, absPath, data, perm) + case batchForwardRemove: + remover := ops.removeFile + if s.batchRemoveOverride != nil { + remover = s.batchRemoveOverride + } + return nil, s.removeAcknowledgedBatchFile(ctx, remover, absPath) + case batchRestoreWrite: + return nil, ops.writeFile(absPath, data, perm) + case batchRestoreRemove: + return nil, ops.removeFile(absPath) + default: + return nil, fmt.Errorf("%w: invalid batch source operation", errMutationNotApplied) + } + } record := s.beginMutationCommit(ctx, tool, mutationID, fingerprint, relPath, absPath) if s.mutationPreCommitHook != nil { s.mutationPreCommitHook(record) @@ -745,14 +842,21 @@ func (s *Server) commitFileMutation( record.markNotApplied(err) return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) } + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + err := fmt.Errorf("%w: source path is not held for mutation", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } // UTF-16 sources refuse every write (#846): the indexer decodes them for // extraction, so their symbols are discoverable and every tool that // writes from graph positions — present or future — funnels here. // Splicing UTF-8 into NUL-interleaved bytes corrupts the file; the // refusal names the encoding and the re-encode escape hatch. - if err := guardUTF16SourceWrite(tool, relPath, absPath); err != nil { - record.markNotApplied(err) - return record, err + if !remove { + if err := guardUTF16SourceWrite(tool, relPath, absPath); err != nil { + record.markNotApplied(err) + return record, err + } } if err := prepareCheckoutMutation(ctx, absPath); err != nil { record.markNotApplied(err) @@ -772,23 +876,100 @@ func (s *Server) commitFileMutation( record.markNotApplied(err) return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) } + var prepared *preparedFileProjection + var baseline *plannedSource + if remove || len(operations) == 0 || operations[0].projection == nil { + baseline = captureFileViewBaseline(ctx, absPath) + } + if remove { + prepared = s.buildRemovalProjection(ctx, relPath, absPath) + } else { + if len(operations) == 1 { + prepared = operations[0].projection + } + if prepared == nil { + if s.indexer == nil && s.multiIndexer == nil { + // There is no parser call to observe. Keep the explicit + // projection refusal without fabricating a parse-gate lap. + prepared, _ = s.buildFileProjection(ctx, absPath, data) + } else { + s.noteAcknowledgementParser(ctx, "source_projection") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + prepared, _ = s.buildFileProjection(ctx, absPath, data) + lap.finish() + } + } + if prepared.key != pathkey.MutationKey(absPath) || prepared.rawHash != blake3.Sum256(data) { + err := fmt.Errorf("%w: prepared source identity changed", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } + } + if prepared.entry != nil && prepared.entry.fileView == nil { + s.prepareFileViewEvidence(ctx, prepared, baseline, data) + } + // A bounded projection may have waited. Recheck cancellation and the live + // captured path proof before the first byte effect, never after writing. + if err := ctx.Err(); err != nil { + record.markNotApplied(err) + return record, fmt.Errorf("%w: %w", errMutationNotApplied, err) + } + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + err := fmt.Errorf("%w: source identity changed during preparation", errMutationNotApplied) + record.markNotApplied(err) + return record, err + } + if err := guardCheckoutMutationPath(ctx, absPath); err != nil { + record.markNotApplied(err) + return record, err + } record.mu.Lock() record.diskWriteStartedAt = time.Now() record.mu.Unlock() indexer.StampPublicationPhase(ctx, indexer.PublicationDiskWriteStarted) diskLap := s.beginAcknowledgementLap(ctx, editAckDiskWrite) - writeErr := agents.AtomicWriteFile(absPath, data, perm) + var writeErr error + if remove { + writeErr = platform.RemoveFile(absPath) + if errors.Is(writeErr, os.ErrNotExist) { + writeErr = nil + } + } else { + writeErr = agents.AtomicWriteFile(absPath, data, perm) + } diskLap.finish() // actual call end, before committed-hash/receipt bookkeeping if err := writeErr; err != nil { record.markFailed(err) return record, err } + contentSHA256 := "" + if !remove { + // One actual raw-byte SHA256, shared by physical receipt and + // checkout publication identity. The graph's BLAKE3 is separate. + contentSHA256 = prepared.contentSHA256 + if contentSHA256 == "" { + // Compatibility for explicitly modeled preparations: production + // preparations already hash their actual candidate bytes once. + sum := sha256.Sum256(data) + contentSHA256 = hex.EncodeToString(sum[:]) + } + } if state := checkoutMutationFromContext(ctx); state != nil { - sum := sha256.Sum256(data) - state.committedHash = hex.EncodeToString(sum[:]) + state.committedHash = contentSHA256 state.committedPath = absPath } - record.markCommitted(gitBlobSHA(data), len(data)) + if remove { + record.markCommitted("", 0) + } else { + record.markCommittedContent(gitBlobSHA(data), len(data), contentSHA256) + } + if len(operations) == 1 && operations[0].kind == sourceCohortWrite { + record.mu.Lock() + record.fileReason = "frontier_not_published" + record.mu.Unlock() + } else { + s.publishCommittedFileProjection(ctx, record, prepared) + } return record, nil } @@ -834,8 +1015,10 @@ func attachMutationCommit(resp map[string]any, record *mutationCommitRecord) { if !record.isPhysicalCommit() { return } + resp["ack_mode"] = record.ackMode resp["mutation_receipt"] = record.id resp["disk_status"] = record.diskStatus() + record.attachFileProjection(resp) if record.key != "" { resp["mutation_id"] = record.key } @@ -886,7 +1069,31 @@ func (s *Server) replayMutation(mutationID, fingerprint string) (map[string]any, "mutation_id %q already committed but its response was not retained; query mutation_status for the receipt: %s", mutationID, encoded), true } + // The disk verdict and receipt remain historical. File-view facts are + // live observations and cannot be replayed after another commit replaced + // the registry entry. Refresh only those scalars on this response clone. + delete(resp, "file_reason") + s.refreshPendingSourceRecord(record) + snap := record.snapshot() + id := snap.ReindexReceipt + if id == "" { + id = snap.publicationReceipt + } + if outcome, found := s.mutationReceiptState(id); found { + if snap.ReindexReceipt == "" { + outcome.Receipt = "" + } + attachMutationFreshnessFields(resp, outcome) + } else { + attachMutationTierState(resp, indexer.MutationTierState{}) + delete(resp, "reindex_pending") + delete(resp, "syntax_health") + resp["graph_status"] = snap.GraphStatus + resp["reindexed"] = snap.GraphStatus == mutationGraphFresh + } + record.attachFileProjection(resp) resp["replayed"] = true + resp["ack_mode"] = record.ackMode resp["mutation_receipt"] = record.id return resp, "", true } diff --git a/internal/mcp/mutation_commit_lock_test.go b/internal/mcp/mutation_commit_lock_test.go new file mode 100644 index 000000000..648f99e84 --- /dev/null +++ b/internal/mcp/mutation_commit_lock_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The commit boundary must prove ownership of this path, rather than relying +// on every current and future caller to remember a separate lock convention. +func TestCommitFileMutationRefusesUnheldSourcePath(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + _, err = srv.commitFileMutation(t.Context(), "edit_file", "unheld", "unheld", "main.go", path, []byte("package main\n"), 0o644) + require.Error(t, err, "an unheld source path must refuse before disk mutation") + after, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, before, after, "a refused unheld commit must preserve exact bytes") +} + +func TestMutationCommitRequiresLiveCapturedPathAdmission(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + t.Cleanup(release) + const accepted = "package main\nfunc Accepted() {}\n" + _, err = srv.commitFileMutation(ctx, "edit_file", "held", "held", "main.go", path, []byte(accepted), 0o644) + require.NoError(t, err) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, accepted, string(current)) + + // A context saved by a returned handler is not a permanent capability. + release() + _, err = srv.commitFileMutation(ctx, "edit_file", "released", "released", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) + current, err = os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, accepted, string(current)) + + otherCtx, otherRelease, err := acquireMutationPath(t.Context(), filepath.Join(dir, "other.go")) + require.NoError(t, err) + defer otherRelease() + _, err = srv.commitFileMutation(otherCtx, "edit_file", "wrong-path", "wrong-path", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) +} + +func TestMutationCommitInheritedAdmissionHonorsParentLifetime(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + parent, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + t.Cleanup(release) + // A nested helper can reuse its caller's set without reacquiring the same + // non-reentrant token. Releasing the child never releases the parent. + child, childRelease, err := acquireMutationPaths(parent, []string{path, path}) + require.NoError(t, err) + childRelease() + _, err = srv.commitFileMutation(child, "edit_file", "parent-held", "parent-held", "main.go", path, []byte("package main\nfunc Nested() {}\n"), 0o644) + require.NoError(t, err) + release() + _, err = srv.commitFileMutation(child, "edit_file", "parent-returned", "parent-returned", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) +} + +func TestMutationCommitRefusesRetargetedParentAlias(t *testing.T) { + srv, dir := setupTestServer(t) + originalDir, otherDir, aliasDir := filepath.Join(dir, "original"), filepath.Join(dir, "other"), filepath.Join(dir, "alias") + require.NoError(t, os.MkdirAll(originalDir, 0o755)) + require.NoError(t, os.MkdirAll(otherDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(originalDir, "file.go"), []byte("package main\nfunc Original() {}\n"), 0o644)) + other := filepath.Join(otherDir, "file.go") + const untouched = "package main\nfunc Other() {}\n" + require.NoError(t, os.WriteFile(other, []byte(untouched), 0o644)) + if err := os.Symlink(originalDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + aliasPath := filepath.Join(aliasDir, "file.go") + ctx, release, err := acquireMutationPath(t.Context(), aliasPath) + require.NoError(t, err) + defer release() + require.NoError(t, os.Remove(aliasDir)) + require.NoError(t, os.Symlink(otherDir, aliasDir)) + _, err = srv.commitFileMutation(ctx, "edit_file", "retargeted", "retargeted", "alias/file.go", aliasPath, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, errMutationNotApplied) + info, err := os.Lstat(aliasDir) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + current, err := os.ReadFile(other) + require.NoError(t, err) + require.Equal(t, untouched, string(current)) +} + +func TestMutationCommitCanceledAdmissionWritesNothing(t *testing.T) { + srv, dir := setupTestServer(t) + path := filepath.Join(dir, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + parent, cancel := context.WithCancel(t.Context()) + ctx, release, err := acquireMutationPath(parent, path) + require.NoError(t, err) + defer release() + cancel() + _, err = srv.commitFileMutation(ctx, "edit_file", "canceled-held", "canceled-held", "main.go", path, []byte("package main\n"), 0o644) + require.ErrorIs(t, err, context.Canceled) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, current) +} diff --git a/internal/mcp/mutation_commit_test.go b/internal/mcp/mutation_commit_test.go index 8e64760ac..a5c9ae9d1 100644 --- a/internal/mcp/mutation_commit_test.go +++ b/internal/mcp/mutation_commit_test.go @@ -49,6 +49,21 @@ func editFileRequest(args map[string]any) mcp.CallToolRequest { return req } +// commitHeldFileMutationForTest supplies the real path lock now required by +// the primitive. Keep the original request context (including cancellation and +// checkout refusal evidence) so these existing deeper-boundary tests still +// exercise their intended guard rather than a lock-admission refusal. +func (s *Server) commitHeldFileMutationForTest(t *testing.T, ctx context.Context, + tool, mutationID, fingerprint, relPath, absPath string, data []byte, perm os.FileMode, +) (*mutationCommitRecord, error) { + t.Helper() + held, release, err := acquireMutationPath(context.Background(), absPath) + require.NoError(t, err) + defer release() + ctx = context.WithValue(ctx, mutationPathAdmissionKey{}, held.Value(mutationPathAdmissionKey{})) + return s.commitFileMutation(ctx, tool, mutationID, fingerprint, relPath, absPath, data, perm) +} + // --- Criterion: timeout BEFORE the write applies nothing ------------------- // A cancelled request must not reach the disk. The handler takes its @@ -155,7 +170,7 @@ func TestAbandonedCallReportsCommittedDiskWrite(t *testing.T) { release := make(chan struct{}) defer close(release) handler := func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - record, err := s.commitFileMutation(ctx, "write_file", "", "fp", "landed.go", target, + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "fp", "landed.go", target, []byte("package p\n"), 0o644) require.NoError(t, err) require.Equal(t, mutationDiskCommitted, record.diskStatus()) @@ -208,7 +223,7 @@ func TestAbandonedCallReportsNotAppliedWrite(t *testing.T) { handler := func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { <-ctx.Done() // the deadline fires first - _, err := s.commitFileMutation(ctx, "write_file", "", "fp", "refused.go", target, + _, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "fp", "refused.go", target, []byte("package p\n"), 0o644) require.Error(t, err) return mcp.NewToolResultText("unreachable"), nil @@ -718,7 +733,14 @@ func TestRenameStopsWritingOnceCancelled(t *testing.T) { } } - results := s.commitRenameWrites(ctx, writes) + paths := make([]string, len(writes)) + for i, w := range writes { + paths[i] = w.AbsPath + } + heldCtx, releasePaths, err := acquireMutationPaths(ctx, paths) + require.NoError(t, err) + defer releasePaths() + results := s.commitRenameWrites(heldCtx, writes, releasePaths) require.Len(t, results, 2, "the rename must stop instead of attempting every remaining file") require.Equal(t, "applied", results[0]["status"]) require.Equal(t, mutationDiskCommitted, results[0]["disk_status"]) diff --git a/internal/mcp/mutation_path_key_test.go b/internal/mcp/mutation_path_key_test.go new file mode 100644 index 000000000..7c476cab2 --- /dev/null +++ b/internal/mcp/mutation_path_key_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zzet/gortex/internal/pathkey" +) + +// Two spellings of one file must share one mutation lock, or two edits of the +// file interleave and one of them is lost. +func TestMutationPathLockSerializesAliasSpellings(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(base, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + realPath := filepath.Join(realDir, "a.go") + if err := os.WriteFile(realPath, []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + + aliases := []string{filepath.Join(aliasDir, "a.go")} + if pathkey.CaseInsensitivePaths { + aliases = append(aliases, filepath.Join(realDir, "A.go")) + } + for _, alias := range aliases { + t.Run(filepath.Base(filepath.Dir(alias))+"/"+filepath.Base(alias), func(t *testing.T) { + _, release, err := acquireMutationPath(context.Background(), realPath) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, aliasRelease, err := acquireMutationPath(ctx, alias) + if err == nil { + aliasRelease() + release() + t.Fatalf("%s acquired the mutation lock while %s held it", alias, realPath) + } + if !errors.Is(err, context.DeadlineExceeded) { + release() + t.Fatalf("alias acquire error = %v, want it to wait for the holder", err) + } + release() + + // One request naming both spellings takes the lock once. + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, both, err := acquireMutationPaths(ctx, []string{realPath, alias}) + if err != nil { + t.Fatalf("acquiring both spellings in one request: %v", err) + } + both() + }) + } +} + +func TestMutationPathsCheckEveryRequestedSpellingBeforeDeduplication(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "a.go") + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(cwd, path) + if err != nil { + t.Fatal(err) + } + if pathkey.MutationKey(relative) != pathkey.MutationKey(path) { + t.Fatal("test paths must name the same mutation key") + } + ctx := withCheckoutMutation(context.Background(), &fakeCheckoutMutationLifecycle{}, root) + _, release, err := acquireMutationPaths(ctx, []string{path, relative}) + if release != nil { + release() + } + if err == nil { + t.Fatal("deduplication bypassed checkout admission for a relative requested spelling") + } + if refs := mutationRefs(path); refs != 0 { + t.Fatalf("refused mutation set retained %d lock references", refs) + } +} diff --git a/internal/mcp/mutation_phase_record_test.go b/internal/mcp/mutation_phase_record_test.go index 23fc7e4d1..af2fe557d 100644 --- a/internal/mcp/mutation_phase_record_test.go +++ b/internal/mcp/mutation_phase_record_test.go @@ -53,7 +53,7 @@ func canonicalTimingContext() context.Context { func commitTimingFixture(t *testing.T, s *Server, ctx context.Context, path, content string) *mutationCommitRecord { t.Helper() - record, err := s.commitFileMutation(ctx, "write_file", "", "", filepath.Base(path), path, []byte(content), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", filepath.Base(path), path, []byte(content), 0o600) require.NoError(t, err) return record } diff --git a/internal/mcp/mutation_release_wait_test.go b/internal/mcp/mutation_release_wait_test.go new file mode 100644 index 000000000..fdba871c9 --- /dev/null +++ b/internal/mcp/mutation_release_wait_test.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +type pathReleaseWatcher struct { + mutationTestWatcher + admitted chan struct{} +} + +func (w pathReleaseWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + close(w.admitted) + return ticket, err +} + +func TestEditFileReleasesPathLockBeforeGraphWait(t *testing.T) { + for _, tool := range []string{"edit_file", "write_file"} { + t.Run(tool, func(t *testing.T) { + s := newMutationServer() + path := filepath.Join(t.TempDir(), "held.txt") + require.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) + done := make(chan indexer.MutationResult, 1) + admitted := make(chan struct{}) + s.SetWatcher(pathReleaseWatcher{mutationTestWatcher: mutationTestWatcher{done: done}, admitted: admitted}) + s.mutationReindexWait = 5 * time.Second + handlerDone := make(chan error, 1) + go func() { + args := map[string]any{"path": path, "content": "after", "old_string": "before", "new_string": "after"} + var result *mcp.CallToolResult + var err error + if tool == "edit_file" { + result, err = s.handleEditFile(t.Context(), editFileRequest(args)) + } else { + result, err = s.handleWriteFile(t.Context(), writeFileRequest(args)) + } + if err == nil && result.IsError { + err = fmt.Errorf("handler refused: %s", resultText(result)) + } + handlerDone <- err + }() + select { + case <-admitted: + case <-time.After(2 * time.Second): + t.Fatal("mutation never reached graph admission") + } + // The graph is deliberately still blocked. Another writer must + // acquire this path before we release the admitted ticket. + waitCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + _, release, err := acquireMutationPath(waitCtx, path) + cancel() + if release != nil { + release() + } + done <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(done) + require.NoError(t, <-handlerDone) + require.NoError(t, err, "a graph wait must not retain the source path lock") + }) + } +} diff --git a/internal/mcp/node_source.go b/internal/mcp/node_source.go index f42565e04..43a886a36 100644 --- a/internal/mcp/node_source.go +++ b/internal/mcp/node_source.go @@ -9,8 +9,11 @@ import ( "slices" "strings" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" ) // errSymbolRemoved reports that a symbol the graph still lists is no longer @@ -42,21 +45,29 @@ func (s *Server) nodeLinesIn(ctx context.Context, node *graph.Node, content []by return node.StartLine, node.EndLine, nil } } - _, local, ok := strings.Cut(node.ID, "::") - if !ok { - return 0, 0, fmt.Errorf("symbol %s has no file-local id", node.ID) - } + + s.noteAcknowledgementParser(ctx, "source_extraction_for_symbol_location") + parseLap := s.beginAcknowledgementLap(ctx, editAckParseGate) extracted, err := owner.ExtractSource(ctx, rel, content) + parseLap.finish() if err != nil { return 0, 0, fmt.Errorf("locate %s in the current file: %w", node.ID, err) } defer extracted.ReleaseTree() + return s.declarationLinesIn(ctx, node, extracted.Nodes) +} + +func (s *Server) declarationLinesIn(ctx context.Context, node *graph.Node, nodes []*graph.Node) (int, int, error) { + _, local, ok := strings.Cut(node.ID, "::") + if !ok { + return 0, 0, fmt.Errorf("symbol %s has no file-local id", node.ID) + } // A declaration is located by its order among the declarations that // share its ID base, as the reader that produced node listed them. An // ID with no position in it is unique, so that is an exact match; a // positional one pairs only while the count holds. base := declarationBase(local) - current := declarationsSharing(extracted.Nodes, base) + current := declarationsSharing(nodes, base) if len(current) == 0 { return 0, 0, errSymbolRemoved } @@ -124,3 +135,20 @@ func (s *Server) readNodeSource(ctx context.Context, node *graph.Node, absPath s } return nodeSource{content: string(indexer.DecodeUTF16Source(raw)), startLine: start, endLine: end}, nil } + +// symbolLinesUnderLock is the write-side resolver. The source snapshot and +// declaration identity are checked only while this request owns the path. +func (s *Server) symbolLinesUnderLock(ctx context.Context, node *graph.Node, absPath string, content []byte) (int, int, error) { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return 0, 0, fmt.Errorf("%w: symbol path is not held", errMutationNotApplied) + } + key := pathkey.MutationKey(absPath) + entry := pendingFileProjections.snap.Load().entries[key] + if entry != nil && !entry.tombstone && entry.graphPath == node.FilePath && entry.rawHash == blake3.Sum256(content) { + start, end, err := s.declarationLinesIn(ctx, node, entry.layer.FileNodes(node.FilePath)) + if pendingFileProjections.snap.Load().entries[key] == entry { + return start, end, err + } + } + return s.nodeLinesIn(ctx, node, content) +} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 6aa059264..f47f35ca8 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -229,11 +229,6 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // read that must stay reachable while publication is pending, and the // tools that must not be hostage to the binding they exist to fix. viewless := catalogOnlyCheckoutControl(controlOperation) || viewlessCatalogTool(legacyName) - // A source mutation preempts background writers while it selects and - // enters its mutation lane. Publication waits temporarily release that - // intent so the previous edit can finish the route this one needs. - ctx, releaseWriteIntent := s.withSourceMutationWriteIntent(ctx, req.Params.Name) - defer releaseWriteIntent() ctx = s.withMutationPublicationStamps(ctx, req.Params.Name) byteCtx, byteView, releaseByte, byteRefusal := s.prepareByteEditAuthority(ctx, &req, selector, freshness, capabilities) if byteRefusal != nil { @@ -265,6 +260,10 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay ctx = withRequestView(ctx, view) defer view.close() } + // The selected route identifies whether this handler writes the view + // store. File-only canonical edits never announce a store writer. + ctx, releaseWriteIntent := s.withSourceMutationWriteIntent(ctx, legacyName) + defer releaseWriteIntent() indexer.StampPublicationPhase(ctx, indexer.PublicationViewResolved) // Tell the deadline firewall what this call is reading and what // repositories it is admitted to. If it stops waiting for this handler diff --git a/internal/mcp/parse_gate.go b/internal/mcp/parse_gate.go index fdf3d2d24..aa68ce314 100644 --- a/internal/mcp/parse_gate.go +++ b/internal/mcp/parse_gate.go @@ -132,6 +132,12 @@ func parseGateEnabled() bool { // to text, binary to binary — carry no parse-error baseline at all, so // they are never a regression. func checkParseGate(path string, oldContent, newContent []byte) parseGateResult { + return checkParseGateWithVerdict(path, oldContent, newContent, false, 0) +} + +// A supplied verdict describes the exact candidate bytes. Unknown input uses +// the original parse; binary transitions and old-byte comparisons stay shared. +func checkParseGateWithVerdict(path string, oldContent, newContent []byte, checked bool, candidateErrors int) parseGateResult { lang := parseGateLanguage(path) if lang == "" { return parseGateResult{Language: lang} @@ -148,7 +154,10 @@ func checkParseGate(path string, oldContent, newContent []byte) parseGateResult Language: lang, } } - newErrs, newOK := parseErrorCount(lang, newContent) + newErrs, newOK := candidateErrors, checked && candidateErrors >= 0 + if !newOK { + newErrs, newOK = parseErrorCount(lang, newContent) + } if !newOK { return parseGateResult{Language: lang} } diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go new file mode 100644 index 000000000..f8c257ca4 --- /dev/null +++ b/internal/mcp/pending_projection.go @@ -0,0 +1,423 @@ +package mcp + +import ( + "context" + "errors" + "io/fs" + "maps" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/zeebo/blake3" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +// The registry refuses a publish over either cap and never evicts a live +// entry to make room: an evicted entry would silently serve stale code. The +// byte cap is the feature's whole memory allowance; file sizes vary too much +// for the file cap alone to bound memory. +const ( + projectionMaxFiles = 512 + projectionMaxBytes = 64 << 20 + + // Retained heap of one projected node and edge, measured with + // BenchmarkProjectionRetainedHeap, for the byte cap. + projectionNodeBytes = 1440 + projectionEdgeBytes = 540 +) + +// pendingFileProjections holds the projection of every file a tool wrote +// whose bytes the store has not caught up with yet. Like the mutation path +// locks it is process-wide and keyed by pathkey.MutationKey. +var pendingFileProjections = newPendingProjections() + +// pendingProjections is a copy-on-write map from a file's mutation key to the +// projection of its latest tool-written bytes. Readers load the snapshot +// without locking; publishers hold mu only to copy the map and swap it, never +// while building an entry. +type pendingProjections struct { + enabled bool + + mu sync.Mutex + snap atomic.Pointer[projectionSnapshot] + refused map[projectionRefusal]int64 // guarded by mu + + published atomic.Int64 + retired atomic.Int64 + externalWrites atomic.Int64 + divergence atomic.Int64 +} + +type projectionSnapshot struct { + entries map[string]*projectionEntry + bytes int64 +} + +// projectionEntry is one file's projection. Everything but suspect is +// immutable once published. +type projectionEntry struct { + key string + absPath string + graphPath string + repoPrefix string + commitSeq uint64 + // hash equals the store's FileMetaRow.ContentHash once the store holds + // these bytes; rawHash covers the bytes on disk for the suspect re-check. + hash string + rawHash [32]byte + contentSHA256 string + disk diskStamp + language string + layer *graph.OverlayLayer + parseErrors int + fileView *fileViewEvidence + parseChecked bool + buildTime time.Duration + size int64 + tombstone bool + // suspect is set by a watcher event for the path; the next lookup + // re-reads the file before serving the entry. + suspect atomic.Bool +} + +// projectionRefusal is why a publish left the live entries as they were. +type projectionRefusal string + +const ( + projectionDisabled projectionRefusal = "disabled" + projectionPathCase projectionRefusal = "path_case" + projectionSuperseded projectionRefusal = "superseded" + projectionOverFiles projectionRefusal = "max_files" + projectionOverBytes projectionRefusal = "max_bytes" +) + +func (r projectionRefusal) Error() string { return "projection not published: " + string(r) } + +// errProjectionExternalWrite reports that the file no longer holds the bytes +// its entry was built from. The entry is dropped; the store answers instead. +var errProjectionExternalWrite = errors.New("projection: file changed on disk since it was projected") + +func newPendingProjections() *pendingProjections { + p := &pendingProjections{ + enabled: os.Getenv("GORTEX_PROJECTION") != "0", + refused: make(map[projectionRefusal]int64), + } + p.snap.Store(&projectionSnapshot{entries: map[string]*projectionEntry{}}) + return p +} + +// diskStamp is what a lookup compares to notice a write the projection did +// not see: size, modification time and file identity. A nil info stamps a +// file that does not exist. +type diskStamp struct { + info os.FileInfo +} + +// statDiskStamp stamps path without following a leaf symlink, matching +// pathkey.MutationKey. +func statDiskStamp(path string) (diskStamp, error) { + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return diskStamp{}, nil + } + if err != nil { + return diskStamp{}, err + } + return diskStamp{info: info}, nil +} + +func (s diskStamp) matches(other diskStamp) bool { + if s.info == nil || other.info == nil { + return s.info == nil && other.info == nil + } + return s.info.Size() == other.info.Size() && + s.info.ModTime().Equal(other.info.ModTime()) && + os.SameFile(s.info, other.info) +} + +// projectFile builds the entry for raw, the bytes a tool wrote to absPath as +// commit commitSeq, and disk, the stamp of that write. It reads no store +// state. +func projectFile(ctx context.Context, idx *indexer.Indexer, absPath string, raw []byte, commitSeq uint64, disk diskStamp) (*projectionEntry, error) { + projection, err := idx.ProjectSource(ctx, absPath, raw) + if err != nil { + return nil, err + } + return newProjectionEntry(absPath, idx.RepoPrefix(), projection, raw, commitSeq, disk), nil +} + +func newProjectionEntry(absPath, repoPrefix string, projection *indexer.Projection, raw []byte, commitSeq uint64, disk diskStamp) *projectionEntry { + layer := graph.NewOverlayLayer() + layer.MarkFile(projection.GraphPath, false) + for _, n := range projection.Nodes { + layer.AddNode(projection.GraphPath, n) + } + for _, e := range projection.Edges { + layer.AddEdge(e) + } + return &projectionEntry{ + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: projection.GraphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + hash: projection.ContentHash, + rawHash: blake3.Sum256(raw), + disk: disk, + language: projection.Language, + layer: layer, + parseErrors: projection.ParseErrors, + parseChecked: projection.ParseChecked, + buildTime: projection.BuildTime, + size: int64(len(projection.Nodes))*projectionNodeBytes + int64(len(projection.Edges))*projectionEdgeBytes, + } +} + +// committedCopy gives each physical execution its own immutable entry. +// The owned layer is immutable and may be shared; the suspect atomic and disk +// identity must never be copied or rewritten on an already-published entry. +func (e *projectionEntry) committedCopy(sequence uint64, disk diskStamp) *projectionEntry { + copy := &projectionEntry{ + key: e.key, absPath: e.absPath, graphPath: e.graphPath, repoPrefix: e.repoPrefix, + commitSeq: sequence, hash: e.hash, rawHash: e.rawHash, contentSHA256: e.contentSHA256, disk: disk, language: e.language, + layer: e.layer, fileView: e.fileView, parseErrors: e.parseErrors, parseChecked: e.parseChecked, + buildTime: e.buildTime, size: e.size, tombstone: e.tombstone, + } + if e.suspect.Load() { + copy.suspect.Store(true) + } + return copy +} + +// newProjectionTombstone is the entry for a file a tool removed: its layer +// hides every symbol the store still holds for the path. +func newProjectionTombstone(absPath, graphPath, repoPrefix string, commitSeq uint64) *projectionEntry { + layer := graph.NewOverlayLayer() + layer.MarkFile(graphPath, true) + return &projectionEntry{ + key: pathkey.MutationKey(absPath), + absPath: absPath, + graphPath: graphPath, + repoPrefix: repoPrefix, + commitSeq: commitSeq, + layer: layer, + tombstone: true, + } +} + +// publish makes e the live projection of its file, replacing an older entry +// at once. It refuses, leaving every live entry in place, when publishing is +// off, when e is older than the live entry, when e names the live entry's +// file in a spelling that matches only after case folding, and when e would +// take the registry over a cap. +func (p *pendingProjections) publish(e *projectionEntry) error { + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + cur := p.snap.Load() + prev := cur.entries[e.key] + bytes := cur.bytes + e.size + if prev != nil { + bytes -= prev.size + } + var refusal projectionRefusal + switch { + case prev != nil && prev.graphPath != e.graphPath: + refusal = projectionPathCase + case prev != nil && prev.commitSeq > e.commitSeq: + refusal = projectionSuperseded + case prev == nil && len(cur.entries) >= projectionMaxFiles: + refusal = projectionOverFiles + case bytes > projectionMaxBytes: + refusal = projectionOverBytes + } + if refusal != "" { + p.refused[refusal]++ + return refusal + } + // A watcher event that landed between the write and this publish + // marked the entry being replaced; it applies to these bytes too. + if prev != nil && prev.suspect.Load() { + e.suspect.Store(true) + } + next := &projectionSnapshot{entries: maps.Clone(cur.entries), bytes: bytes} + next.entries[e.key] = e + p.snap.Store(next) + p.published.Add(1) + return nil +} + +// lookup returns the live projection of path, or nil when there is none. +// The file is stat-ed before an entry is served; when it no longer holds the +// projected bytes the entry is dropped and errProjectionExternalWrite +// returned, so the caller re-projects or reads the store. +func (p *pendingProjections) lookup(path string) (*projectionEntry, error) { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return nil, nil + } + e := snap.entries[pathkey.MutationKey(path)] + if e == nil { + return nil, nil + } + if !e.onDisk() { + p.drop(e) + p.externalWrites.Add(1) + return nil, errProjectionExternalWrite + } + return e, nil +} + +func (e *projectionEntry) onDisk() bool { + stamp, err := statDiskStamp(e.absPath) + if err != nil || !e.disk.matches(stamp) { + return false + } + if e.tombstone || !e.suspect.Swap(false) { + return true + } + raw, err := os.ReadFile(e.absPath) + return err == nil && blake3.Sum256(raw) == e.rawHash +} + +// markSuspect makes the next lookup of path re-read the file before serving +// its entry. The watcher calls it for every event, before debouncing. +func (p *pendingProjections) markSuspect(path string) { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return + } + if e := snap.entries[pathkey.MutationKey(path)]; e != nil { + e.suspect.Store(true) + } +} + +func (p *pendingProjections) drop(e *projectionEntry) { + p.dropAll([]*projectionEntry{e}) +} + +// dropAll removes each entry that is still the live one for its file; an +// entry already replaced by a newer publish stays replaced. +func (p *pendingProjections) dropAll(entries []*projectionEntry) int { + if len(entries) == 0 { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + cur := p.snap.Load() + next := &projectionSnapshot{bytes: cur.bytes} + dropped := 0 + for _, e := range entries { + if cur.entries[e.key] != e { + continue + } + if next.entries == nil { + next.entries = maps.Clone(cur.entries) + } + delete(next.entries, e.key) + next.bytes -= e.size + dropped++ + } + if dropped > 0 { + p.snap.Store(next) + } + return dropped +} + +// projectionStore is what retirement reads: a file's content identity, then +// its nodes. +type projectionStore interface { + graph.FileMetaPathReader + GetFileNodes(filePath string) []*graph.Node +} + +// retirePaths verifies only the physical files in one publication frontier. +// Filtering happens before store access: equal graph paths/prefixes/hashes in +// another checkout are not evidence about that checkout's backend. +func (p *pendingProjections) retirePaths(store projectionStore, absPaths []string) int { + if len(absPaths) == 0 { + return 0 + } + selected := make(map[string]struct{}, len(absPaths)) + for _, path := range absPaths { + selected[pathkey.MutationKey(path)] = struct{}{} + } + return p.retireSelected(store, selected) +} + +// retire retains the explicit all-entry helper for tests/whole-owner callers. +// Runtime publication callbacks use retirePaths with their physical frontier. +func (p *pendingProjections) retire(store projectionStore) int { + return p.retireSelected(store, nil) +} + +// A receipt alone is never retirement evidence. Both entry points preserve +// durable hash -> node comparison -> durable hash and pointer-fenced COW drop. +func (p *pendingProjections) retireSelected(store projectionStore, selected map[string]struct{}) int { + snap := p.snap.Load() + if len(snap.entries) == 0 { + return 0 + } + byRepo := make(map[string][]*projectionEntry) + for key, e := range snap.entries { + if selected != nil { + if _, ok := selected[key]; !ok { + continue + } + } + byRepo[e.repoPrefix] = append(byRepo[e.repoPrefix], e) + } + var caughtUp []*projectionEntry + for prefix, entries := range byRepo { + candidates := storeHolds(store, prefix, entries) + diverged := make(map[*projectionEntry]bool, len(candidates)) + for _, e := range candidates { + stored := store.GetFileNodes(e.graphPath) + diverged[e] = len(indexer.DiffProjectedNodes(e.layer.FileNodes(e.graphPath), stored)) > 0 + } + for _, e := range storeHolds(store, prefix, candidates) { + if diverged[e] { + p.divergence.Add(1) + } + caughtUp = append(caughtUp, e) + } + } + retired := p.dropAll(caughtUp) + p.retired.Add(int64(retired)) + return retired +} + +// storeHolds returns the entries whose content the store holds now. +func storeHolds(store projectionStore, prefix string, entries []*projectionEntry) []*projectionEntry { + if len(entries) == 0 { + return nil + } + paths := make([]string, len(entries)) + for i, e := range entries { + paths[i] = e.graphPath + } + rows, err := store.FileMetasByPaths(prefix, paths) + if err != nil { + return nil + } + var held []*projectionEntry + for _, e := range entries { + row, ok := rows[e.graphPath] + if e.tombstone { + ok = !ok + } else { + ok = ok && row.ContentHash == e.hash + } + if ok { + held = append(held, e) + } + } + return held +} diff --git a/internal/mcp/pending_projection_cohort.go b/internal/mcp/pending_projection_cohort.go new file mode 100644 index 000000000..e28dd4624 --- /dev/null +++ b/internal/mcp/pending_projection_cohort.go @@ -0,0 +1,79 @@ +package mcp + +import ( + "errors" + "maps" +) + +// Every member was prepared and verified outside the registry mutex while its +// original source frontier was held. Expected is the exact old COW pointer; +// nil means that no entry existed. This is cache admission, never disk or +// journal authority. A refusal changes none of the registry. +type projectionCohortMember struct { + Entry *projectionEntry + Expected *projectionEntry +} + +func (p *pendingProjections) publishCohort(members []projectionCohortMember) error { + if len(members) == 0 { + return nil + } + if len(members) > projectionMaxFiles { + return projectionOverFiles + } + if !p.enabled { + return projectionDisabled + } + p.mu.Lock() + defer p.mu.Unlock() + current := p.snap.Load() + bytes := current.bytes + files := len(current.entries) + seen := make(map[string]struct{}, len(members)) + refuse := func(reason projectionRefusal) error { p.refused[reason]++; return reason } + for _, member := range members { + entry := member.Entry + if entry == nil || entry.key == "" || entry.graphPath == "" || entry.size < 0 { + return errors.New("projection cohort contains invalid entry") + } + if _, duplicate := seen[entry.key]; duplicate { + return errors.New("projection cohort contains duplicate physical path") + } + seen[entry.key] = struct{}{} + prior := current.entries[entry.key] + if prior != member.Expected { + return refuse(projectionSuperseded) + } + if prior != nil { + if prior.graphPath != entry.graphPath { + return refuse(projectionPathCase) + } + if prior.commitSeq > entry.commitSeq { + return refuse(projectionSuperseded) + } + bytes -= prior.size + } else { + files++ + } + if entry.size > projectionMaxBytes { + return refuse(projectionOverBytes) + } + bytes += entry.size + } + if bytes > projectionMaxBytes { + return refuse(projectionOverBytes) + } + if files > projectionMaxFiles { + return refuse(projectionOverFiles) + } + next := &projectionSnapshot{entries: maps.Clone(current.entries), bytes: bytes} + for _, member := range members { + if member.Expected != nil && member.Expected.suspect.Load() { + member.Entry.suspect.Store(true) + } + next.entries[member.Entry.key] = member.Entry + } + p.snap.Store(next) + p.published.Add(int64(len(members))) + return nil +} diff --git a/internal/mcp/pending_projection_cohort_test.go b/internal/mcp/pending_projection_cohort_test.go new file mode 100644 index 000000000..15c7b5999 --- /dev/null +++ b/internal/mcp/pending_projection_cohort_test.go @@ -0,0 +1,80 @@ +package mcp + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProjectionCohortRefusalLeavesEveryOldPointerAndBudget(t *testing.T) { + for _, failure := range []string{"bytes", "files", "stale pointer", "older sequence", "duplicate"} { + t.Run(failure, func(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + oldA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: 10} + oldB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 1, size: 10} + require.NoError(t, registry.publish(oldA)) + require.NoError(t, registry.publish(oldB)) + if failure == "files" { + for i := 2; i < projectionMaxFiles; i++ { + key := fmt.Sprintf("f%d", i) + require.NoError(t, registry.publish(&projectionEntry{key: key, graphPath: key, size: 1})) + } + } + first := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: 20} + second := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 20} + members := []projectionCohortMember{{Entry: first, Expected: oldA}, {Entry: second, Expected: oldB}} + switch failure { + case "bytes": + second.size = projectionMaxBytes + case "files": + members = append(members, projectionCohortMember{Entry: &projectionEntry{key: "new", graphPath: "new.go", commitSeq: 2, size: 1}}) + case "stale pointer": + members[1].Expected = &projectionEntry{} + case "older sequence": + second.commitSeq = 0 + case "duplicate": + second.key = "a" + } + before := registry.snap.Load() + require.Error(t, registry.publishCohort(members)) + require.Same(t, before, registry.snap.Load(), "no member or byte accounting can advance on a cohort refusal") + require.Same(t, oldA, registry.snap.Load().entries["a"]) + require.Same(t, oldB, registry.snap.Load().entries["b"]) + }) + } +} + +func TestProjectionCohortPublishesOneCompleteSnapshotAndPreservesSuspect(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + old := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: 10} + old.suspect.Store(true) + require.NoError(t, registry.publish(old)) + before := registry.snap.Load() + first := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: 20} + second := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 30} + require.NoError(t, registry.publishCohort([]projectionCohortMember{{Entry: first, Expected: old}, {Entry: second}})) + after := registry.snap.Load() + require.NotSame(t, before, after) + require.EqualValues(t, 50, after.bytes) + require.Same(t, first, after.entries["a"]) + require.Same(t, second, after.entries["b"]) + require.True(t, first.suspect.Load()) + require.Len(t, before.entries, 1) + require.Same(t, old, before.entries["a"]) +} + +func TestProjectionCohortBudgetUsesFinalCombinedReplacement(t *testing.T) { + registry := newPendingProjections() + registry.enabled = true + oldA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 1, size: projectionMaxBytes - 20} + oldB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 1, size: 20} + require.NoError(t, registry.publish(oldA)) + require.NoError(t, registry.publish(oldB)) + nextA := &projectionEntry{key: "a", graphPath: "a.go", commitSeq: 2, size: projectionMaxBytes - 10} + nextB := &projectionEntry{key: "b", graphPath: "b.go", commitSeq: 2, size: 10} + require.NoError(t, registry.publishCohort([]projectionCohortMember{{Entry: nextA, Expected: oldA}, {Entry: nextB, Expected: oldB}})) + require.EqualValues(t, projectionMaxBytes, registry.snap.Load().bytes) +} diff --git a/internal/mcp/pending_projection_retire_paths_test.go b/internal/mcp/pending_projection_retire_paths_test.go new file mode 100644 index 000000000..f20e93c8c --- /dev/null +++ b/internal/mcp/pending_projection_retire_paths_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPendingProjectionRetirementDoesNotCrossPhysicalCheckouts(t *testing.T) { + a, b := newProjectionFixture(t), newProjectionFixture(t) + p := newPendingProjections() + const current = "package p\n\nfunc New() {}\n" + ea, eb := a.edit("a.go", current), b.edit("a.go", current) + require.Equal(t, ea.graphPath, eb.graphPath) + require.Equal(t, ea.repoPrefix, eb.repoPrefix) + require.Equal(t, ea.hash, eb.hash, "equal graph paths and byte identities cannot select a physical checkout") + require.NotEqual(t, ea.key, eb.key) + require.NoError(t, p.publish(ea)) + require.NoError(t, p.publish(eb)) + require.NoError(t, a.idx.IndexFile(filepath.Join(a.dir, "a.go"))) + require.NotNil(t, b.g.GetNode("a.go::Old"), "checkout B's backend has not caught up") + assert.Equal(t, 1, p.retirePaths(a.g, []string{ea.absPath}), "A publication may verify only A's physical frontier") + require.Nil(t, liveEntry(p, ea.absPath)) + require.Same(t, eb, liveEntry(p, eb.absPath), "A's matching store hash must not retire B's still-pending graph") + require.NoError(t, b.idx.IndexFile(filepath.Join(b.dir, "a.go"))) + require.Equal(t, 1, p.retirePaths(b.g, []string{eb.absPath})) + require.Nil(t, liveEntry(p, eb.absPath)) +} + +func TestPendingProjectionScopedRetirementEmptySetDoesNoStoreWork(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + e := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(e)) + store := &projectionRetirementSpy{Graph: f.g} + require.Zero(t, p.retirePaths(store, nil)) + require.Zero(t, store.metadataReads) + require.Zero(t, store.nodeReads) + require.Same(t, e, liveEntry(p, e.absPath)) +} diff --git a/internal/mcp/read_repair_observation_test.go b/internal/mcp/read_repair_observation_test.go index b3cea416b..3128fe2fa 100644 --- a/internal/mcp/read_repair_observation_test.go +++ b/internal/mcp/read_repair_observation_test.go @@ -108,7 +108,7 @@ func testReadRepairBeforeWatcherAdmission(t *testing.T, inFlight, earlyExecution } } } - record, err := s.commitFileMutation(ctx, "write_file", "logical-reader-admission", "reader admission fixture", "main.go", path, after, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "logical-reader-admission", "reader admission fixture", "main.go", path, after, 0o600) require.NoError(t, err) require.Nil(t, record.publication) require.Nil(t, record.readRepairAdmission.Value()) @@ -243,7 +243,7 @@ func TestReadRepairSeesCommittedAdmissionBeforeACKAndSurvivesSuccessClear(t *tes ctx, cancel := context.WithCancel(canonicalTimingContext()) t.Cleanup(cancel) path := filepath.Join(t.TempDir(), "edit.go") - record, err := s.commitFileMutation(ctx, "write_file", "caller-logical-id", "distinct-id fixture", filepath.Base(path), path, []byte("package edited\n"), 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "caller-logical-id", "distinct-id fixture", filepath.Base(path), path, []byte("package edited\n"), 0o600) require.NoError(t, err) idx := &indexer.Indexer{} outcome := make(chan mutationReindexOutcome, 1) diff --git a/internal/mcp/read_repair_straddling_commit_test.go b/internal/mcp/read_repair_straddling_commit_test.go index 92b462419..366f13048 100644 --- a/internal/mcp/read_repair_straddling_commit_test.go +++ b/internal/mcp/read_repair_straddling_commit_test.go @@ -98,7 +98,7 @@ func TestReadRepairActualExecutorStraddlesCommittedBookkeeping(t *testing.T) { t.Fatal("actual executor did not enter its first phase before commit") } } - record, err := s.commitFileMutation(ctx, "write_file", "logical-straddling", "straddling fixture", "main.go", path, after, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "logical-straddling", "straddling fixture", "main.go", path, after, 0o600) require.NoError(t, err) waiterDone := make(chan mutationReindexOutcome, 1) go func() { waiterDone <- s.mutationReindexState(ctx, path) }() diff --git a/internal/mcp/rename_apply.go b/internal/mcp/rename_apply.go index f9251e213..e28d5945d 100644 --- a/internal/mcp/rename_apply.go +++ b/internal/mcp/rename_apply.go @@ -320,14 +320,15 @@ func splitLinesKeepEnds(content string) []string { // renameFileWrite is one file's fully-computed rewrite, held in memory until // every affected file has passed verification. type renameFileWrite struct { - RelPath string - AbsPath string - Old []byte - New []byte - Edits int - NewSHA string - Gate parseGateResult - GateInfo map[string]any + RelPath string + AbsPath string + Old []byte + New []byte + Edits int + NewSHA string + Gate parseGateResult + GateInfo map[string]any + projection *preparedFileProjection } // planRenameWrites resolves every affected file, re-verifies each target line @@ -353,17 +354,17 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow for _, relPath := range files { absPath, err := s.resolveGraphPath(ctx, relPath) if err != nil { - return nil, fmt.Errorf("could not resolve %s: %w", relPath, err) + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf("could not resolve %s: %w", relPath, err)) } content, err := os.ReadFile(absPath) if err != nil { - return nil, fmt.Errorf("could not read %s: %w", relPath, err) + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf("could not read %s: %w", relPath, err)) } // The rename rewrites graph lines into the raw bytes. On a UTF-16 // source that splices UTF-8 into NUL-interleaved text (#846); refuse // at plan time so even a dry run shows the encoding refusal. if indexer.LooksUTF16Source(content) { - return nil, refuseUTF16Edit("rename_symbol", relPath) + return s.finishRenamePlans(ctx, writes, allowParseErrors, refuseUTF16Edit("rename_symbol", relPath)) } lines := splitLinesKeepEnds(string(content)) @@ -371,18 +372,18 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow sort.Slice(fileEdits, func(i, j int) bool { return fileEdits[i].Line < fileEdits[j].Line }) for _, e := range fileEdits { if e.Line < 1 || e.Line > len(lines) { - return nil, fmt.Errorf( + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf( "%s:%d is outside the file (%d lines) — the index is stale relative to disk; re-run after reindexing", - relPath, e.Line, len(lines)) + relPath, e.Line, len(lines))) } body, term := splitLineTerminator(lines[e.Line-1]) if body != e.OldText { // The graph and disk disagree, so the planned replacement was // computed against content that is no longer there. Refuse // rather than write a line we never actually inspected. - return nil, fmt.Errorf( + return s.finishRenamePlans(ctx, writes, allowParseErrors, fmt.Errorf( "%s:%d changed on disk since it was planned — refusing the rename so no unreviewed line is rewritten. Re-run rename after the file settles", - relPath, e.Line) + relPath, e.Line)) } lines[e.Line-1] = e.NewText + term } @@ -396,25 +397,23 @@ func (s *Server) planRenameWrites(ctx context.Context, edits []renameEdit, allow Edits: len(fileEdits), NewSHA: gitBlobSHA(newContent), } - if parseGateEnabled() { - w.Gate = checkParseGate(relPath, content, newContent) - if w.Gate.Blocked && !allowParseErrors { - return nil, fmt.Errorf("%s", parseGateError(relPath, w.Gate)) - } - } - w.GateInfo = parseGateInfo(w.Gate, allowParseErrors) writes = append(writes, w) } - return writes, nil + return s.finishRenamePlans(ctx, writes, allowParseErrors, nil) } // commitRenameWrites writes each verified file atomically and reports the // per-file outcome. Callers must already hold the mutation locks for every -// path in writes. -func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWrite) []map[string]any { +// path in writes. The supplied release returns that entire set after admission +// and before any publication wait; callers also defer it for early failures. +func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWrite, releasePaths func()) []map[string]any { sess := s.sessionFor(ctx) results := make([]map[string]any, 0, len(writes)) - for i, w := range writes { + var targets []sourceMutationTarget + var records []*mutationCommitRecord + var projections []committedFileProjection + var applied []map[string]any + for _, w := range writes { entry := map[string]any{ "file": w.RelPath, "edits": w.Edits, @@ -432,33 +431,52 @@ func (s *Server) commitRenameWrites(ctx context.Context, writes []*renameFileWri // grinding through the rest: every remaining file would be refused // anyway, and the receipts already recorded say exactly how far the // rename got. - commit, err := s.commitFileMutation(ctx, "rename_symbol", "", "", w.RelPath, w.AbsPath, w.New, perm) + if w.projection == nil { + before := baselineForPreparedWrite(w.AbsPath, w.Old) + if before == nil { + before = captureFileViewBaseline(ctx, w.AbsPath) + } + if s.indexer == nil && s.multiIndexer == nil { + w.projection, _ = s.buildFileProjection(ctx, w.AbsPath, w.New) + } else { + s.noteAcknowledgementParser(ctx, "source_projection") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + w.projection, _ = s.buildFileProjection(ctx, w.AbsPath, w.New) + lap.finish() + } + s.prepareFileViewEvidence(ctx, w.projection, before, w.New) + } + commit, err := s.commitFileMutation(ctx, "rename_symbol", "", "", w.RelPath, w.AbsPath, w.New, perm, sourceMutationOperation{kind: sourceCohortWrite, projection: w.projection}) if err != nil { entry["status"] = "failed" entry["error"] = err.Error() attachMutationCommit(entry, commit) results = append(results, entry) if errors.Is(err, errMutationNotApplied) { - return results + break } continue } entry["status"] = "applied" sess.recordModified(w.RelPath) - // Later files still write and may reindex inline, so only the last - // file's wait gives the dispatch slot back. - waitCtx := ctx - if i < len(writes)-1 { - waitCtx = withoutDispatchSlotRelease(ctx) - } - outcome := s.mutationReindexState(waitCtx, w.AbsPath) + targets = append(targets, captureSourceMutationTarget(ctx, w.AbsPath)) + records = append(records, commit) + projections = append(projections, committedFileProjection{record: commit, prepared: w.projection}) + applied = append(applied, entry) + results = append(results, entry) + } + s.publishCommittedProjectionCohort(ctx, projections) + admissions := s.admitSourceMutationFrontier(ctx, targets) + releasePaths() + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, outcome := range outcomes { + entry, target, commit := applied[i], targets[i], records[i] commit.recordGraph(outcome) if outcome.Err != nil { entry["reindex_error"] = outcome.Err.Error() } - s.attachMutationFreshness(entry, w.RelPath, w.AbsPath, outcome) + s.attachMutationFreshness(entry, entry["file"].(string), target.path, outcome) attachMutationCommit(entry, commit) - results = append(results, entry) } return results } diff --git a/internal/mcp/safe_delete_propagate.go b/internal/mcp/safe_delete_propagate.go index 2a92f3b6f..4be9adb2a 100644 --- a/internal/mcp/safe_delete_propagate.go +++ b/internal/mcp/safe_delete_propagate.go @@ -3,12 +3,19 @@ package mcp import ( "context" "fmt" + "go/ast" + "go/parser" + "maps" "os" + "path/filepath" + "slices" "sort" "strings" - "github.com/zzet/gortex/internal/agents" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer/source" ) // SRN-7: transitive propagate-delete. computeCascadeClosure already cascades @@ -16,63 +23,133 @@ import ( // reference the deleted symbol. buildPropagationPlan reads each call site and // classifies it — a standalone statement call can be removed outright; an // embedded reference is flagged for manual patching — and applyRemoveLinePatches -// deletes the removable lines (parse-gate validated) so the delete leaves no -// dangling reference behind. +// deletes the removable lines (text-anchored, parse-gate validated) so the +// delete leaves no dangling reference behind. type callerPatch struct { - File string `json:"file"` - CallerName string `json:"caller_name"` - Line int `json:"line"` - CurrentText string `json:"current_text"` - Action string `json:"action"` // remove_line | manual - Reason string `json:"reason"` - abs string // resolved absolute path (internal) + File string `json:"file"` + CallerName string `json:"caller_name"` + Line int `json:"line"` + CurrentText string `json:"current_text"` + Action string `json:"action"` // remove_line | manual + Reason string `json:"reason"` + NeedsSourceRevalidation bool `json:"source_revalidation_required,omitempty"` + abs string // resolved absolute path (internal) + from, target string + kind graph.EdgeKind + sourceHash [32]byte } // buildPropagationPlan walks the referencing call sites of a symbol and returns // the per-caller patch each one needs. func (s *Server) buildPropagationPlan(ctx context.Context, g graph.Reader, target *graph.Node) []callerPatch { + return s.buildPropagationPlanForPhase(ctx, g, target, false) +} + +func (s *Server) buildPropagationPreview(ctx context.Context, g graph.Reader, target *graph.Node) []callerPatch { + return s.buildPropagationPlanForPhase(ctx, g, target, true) +} + +func (s *Server) buildPropagationPlanForPhase(ctx context.Context, g graph.Reader, target *graph.Node, preview bool) []callerPatch { var plan []callerPatch - seen := make(map[string]bool) - for _, e := range g.GetInEdges(target.ID) { - if !isReferencingEdgeKind(e.Kind) || e.Line == 0 { + seen := make(map[string]int) + for _, edge := range g.GetInEdges(target.ID) { + if !isReferencingEdgeKind(edge.Kind) { continue } - caller := g.GetNode(e.From) - if caller == nil { - continue + patch := callerPatch{File: edge.FilePath, Line: edge.Line, Action: "manual", Reason: "reference has no verified isolated source occurrence", from: edge.From, target: edge.To, kind: edge.Kind} + caller := g.GetNode(edge.From) + if caller != nil { + patch.CallerName = caller.Name + if patch.File == "" { + patch.File = caller.FilePath + } } - key := fmt.Sprintf("%s:%d", caller.FilePath, e.Line) - if seen[key] { + if caller == nil || edge.Line <= 0 || edge.Kind != graph.EdgeCalls || patch.File != caller.FilePath { + plan = append(plan, patch) continue } - seen[key] = true - abs, err := s.resolveNodePath(ctx, caller) if err != nil { + plan = append(plan, patch) continue } - text := readSingleLineAt(abs, e.Line) - action, reason := classifyCallSite(text, target.Name) - plan = append(plan, callerPatch{ - File: caller.FilePath, - CallerName: caller.Name, - Line: e.Line, - CurrentText: strings.TrimSpace(text), - Action: action, - Reason: reason, - abs: abs, - }) + patch.abs = abs + raw, accepted := s.acceptedPropagationSource(ctx, g, caller, abs) + lines := strings.Split(string(raw), "\n") + if len(raw) == 0 || !accepted && !preview || edge.Line > len(lines) { + patch.Reason = "caller source is not the selected graph's accepted content — publish it or patch by hand" + plan = append(plan, patch) + continue + } + patch.CurrentText = strings.TrimSpace(lines[edge.Line-1]) + patch.Action, patch.Reason = classifyCallSite(patch.CurrentText, target.Name) + patch.sourceHash = blake3.Sum256(raw) + if !accepted && patch.Action == "remove_line" { + patch.NeedsSourceRevalidation = true + patch.Reason = "isolated call in current source; this preview requires source and reference revalidation before applying" + } + key := fmt.Sprintf("%s:%d", patch.File, patch.Line) + if previous, ok := seen[key]; ok { + // More than one graph occurrence cannot be discharged by one + // textual line deletion. Keep both references manually actionable. + plan[previous].Action, plan[previous].Reason = "manual", "multiple references share this source line — patch by hand" + patch.Action, patch.Reason = "manual", "multiple references share this source line — patch by hand" + } else { + seen[key] = len(plan) + } + plan = append(plan, patch) } sort.Slice(plan, func(a, b int) bool { if plan[a].File != plan[b].File { return plan[a].File < plan[b].File } - return plan[a].Line < plan[b].Line + if plan[a].Line != plan[b].Line { + return plan[a].Line < plan[b].Line + } + if plan[a].from != plan[b].from { + return plan[a].from < plan[b].from + } + if plan[a].kind != plan[b].kind { + return plan[a].kind < plan[b].kind + } + return plan[a].target < plan[b].target }) return plan } +// Automatic source removal needs the same selected graph's accepted bytes, +// not merely a matching callee name on a line in a changed file. Unknown or +// transformed identities conservatively stay manual; there is no canonical +// fallback for a routed reader. +func (s *Server) acceptedPropagationSource(ctx context.Context, reader graph.Reader, caller *graph.Node, abs string) ([]byte, bool) { + files, err := source.NewFilesystemSource(filepath.Dir(abs)) + if err != nil { + return nil, false + } + defer files.Close() + raw, _, err := files.ReadRegularFile(ctx, filepath.Base(abs), 1<<20) + if err != nil { + return nil, false + } + checked, ok := reader.(graph.ConstantValueProjectionReader) + if !ok { + return raw, false + } + prefix := caller.RepoPrefix + if owner, _ := s.indexerForRel(caller.FilePath); owner != nil { + prefix = owner.RepoPrefix() + } + key := graph.ConstantFileKey{RepoPrefix: prefix, FilePath: caller.FilePath} + projection, err := checked.ReadConstantValueProjectionContext(ctx, nil, []graph.ConstantFileKey{key}) + if err != nil { + return raw, false + } + row, ok := projection.Files[key] + sum := blake3.Sum256(raw) + return raw, ok && row.ContentHash == fmt.Sprintf("%x", sum[:]) +} + // classifyCallSite decides whether a reference line is a standalone statement // call (safe to delete whole) or an embedded reference (manual). Conservative: // anything ambiguous is manual. @@ -92,60 +169,126 @@ func classifyCallSite(line, target string) (action, reason string) { } } core := strings.TrimPrefix(strings.TrimPrefix(body, "defer "), "go ") - if strings.HasSuffix(core, ")") && - (strings.HasPrefix(core, target+"(") || strings.Contains(core, "."+target+"(")) { - return "remove_line", "standalone statement call — the line can be removed" + // Parse one expression, not a line prefix: Target(); Keep() is two + // statements, and deleting its whole line would erase unrelated work. + if expression, err := parser.ParseExpr(core); err == nil { + if call, ok := expression.(*ast.CallExpr); ok { + name := "" + switch callee := call.Fun.(type) { + case *ast.Ident: + name = callee.Name + case *ast.SelectorExpr: + name = callee.Sel.Name + } + if name == target { + return "remove_line", "isolated standalone call — the line can be removed" + } + } } return "manual", "could not confirm a standalone call — patch by hand" } -// applyRemoveLinePatches deletes the remove_line patches from their files, -// grouped per file and applied bottom-up so earlier line numbers stay valid. -// A patch whose removal would introduce new parse errors is not applied and is -// returned in failed. Manual patches are ignored here. -func (s *Server) applyRemoveLinePatches(plan []callerPatch) (applied, failed []callerPatch, err error) { +// applyRemoveLinePatches deletes the remove_line patches from their files. +// Manual patches are ignored here. Every caller file is locked before any is +// written, so a lock that cannot be taken (a cancelled request, a path the +// checkout guard refuses) leaves them all untouched. Each file is then handled +// by removePlannedLines; a refused patch lands in failed exactly once, with +// its Reason replaced by the refusal code. +func (s *Server) applyRemoveLinePatches(ctx context.Context, plan []callerPatch) (applied, failed []callerPatch, err error) { byFile := make(map[string][]callerPatch) for _, p := range plan { if p.Action == "remove_line" { byFile[p.abs] = append(byFile[p.abs], p) } } - for abs, patches := range byFile { - content, readErr := os.ReadFile(abs) - if readErr != nil { - return applied, failed, fmt.Errorf("read %s: %w", abs, readErr) - } - lines := strings.Split(string(content), "\n") - // Apply bottom-up so deletions don't shift later indices. - sort.Slice(patches, func(a, b int) bool { return patches[a].Line > patches[b].Line }) - kept := patches[:0] - newLines := append([]string{}, lines...) - for _, p := range patches { - if p.Line < 1 || p.Line > len(newLines) { - failed = append(failed, p) - continue - } - newLines = append(newLines[:p.Line-1], newLines[p.Line:]...) - kept = append(kept, p) - } - newContent := []byte(strings.Join(newLines, "\n")) - // Parse gate: never let a propagated removal corrupt a file. - relPath := patches[0].File - if gate := checkParseGate(relPath, content, newContent); gate.Blocked { - failed = append(failed, patches...) - continue + paths := slices.Sorted(maps.Keys(byFile)) + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return nil, nil, fmt.Errorf("lock caller files: %w", err) + } + defer release() + applied, failed, writes, err := s.applyRemoveLinePatchesHeld(ctx, plan) + s.completeCommittedSourceMutations(ctx, writes, release) + return applied, failed, err +} + +func (s *Server) applyRemoveLinePatchesHeld(ctx context.Context, plan []callerPatch) (applied, failed []callerPatch, writes []committedSourceMutation, err error) { + byFile := make(map[string][]callerPatch) + for _, patch := range plan { + if patch.Action == "remove_line" { + byFile[patch.abs] = append(byFile[patch.abs], patch) } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(abs); statErr == nil { - perm = info.Mode().Perm() + } + for _, abs := range slices.Sorted(maps.Keys(byFile)) { + ok, refused, commit, ferr := s.removePlannedLines(ctx, abs, byFile[abs]) + applied = append(applied, ok...) + failed = append(failed, refused...) + if commit != nil && ferr == nil { + writes = append(writes, captureCommittedSourceMutation(ctx, abs, commit)) } - if writeErr := agents.AtomicWriteFile(abs, newContent, perm); writeErr != nil { - return applied, failed, fmt.Errorf("write %s: %w", abs, writeErr) + if ferr != nil { + return applied, failed, writes, ferr } - s.reindexFile(abs) - applied = append(applied, kept...) } - return applied, failed, nil + return applied, failed, writes, nil +} + +// removePlannedLines applies one file's patches bottom-up, so earlier line +// numbers stay valid; the caller holds the file's mutation lock, so no +// concurrent writer lands between the read and the write. A patch is anchored +// on its planned text: a line that was edited or shifted since planning is +// refused as line_changed instead of being deleted by number. When the +// surviving removals would introduce new parse errors the file is left +// untouched and they are refused as parse_errors. Nothing is written when +// every patch is refused. +func (s *Server) removePlannedLines(ctx context.Context, abs string, patches []callerPatch) (applied, failed []callerPatch, committed *mutationCommitRecord, err error) { + content, err := os.ReadFile(abs) + if err != nil { + return nil, nil, nil, fmt.Errorf("read %s: %w", abs, err) + } + lines := strings.Split(string(content), "\n") + sort.Slice(patches, func(a, b int) bool { return patches[a].Line > patches[b].Line }) + for _, p := range patches { + switch { + case p.NeedsSourceRevalidation: + p.Reason = "source_unverified" + failed = append(failed, p) + case p.Line < 1 || p.Line > len(lines): + p.Reason = "line_out_of_range" + failed = append(failed, p) + case strings.TrimSpace(lines[p.Line-1]) != p.CurrentText: + p.Reason = "line_changed" + failed = append(failed, p) + case p.sourceHash != ([32]byte{}) && p.sourceHash != blake3.Sum256(content): + p.Reason = "file_changed" + failed = append(failed, p) + default: + lines = append(lines[:p.Line-1], lines[p.Line:]...) + applied = append(applied, p) + } + } + if len(applied) == 0 { + return nil, failed, nil, nil + } + newContent := []byte(strings.Join(lines, "\n")) + // Parse gate: never let a propagated removal corrupt a file. + prepared, gate := s.prepareAcknowledgedProjection(ctx, patches[0].File, abs, content, newContent) + if gate.Blocked { + for _, p := range applied { + p.Reason = "parse_errors" + failed = append(failed, p) + } + return nil, failed, nil, nil + } + perm := os.FileMode(0o644) + if info, statErr := os.Stat(abs); statErr == nil { + perm = info.Mode().Perm() + } + commit, writeErr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", patches[0].File, abs, newContent, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + if writeErr != nil { + return nil, failed, nil, fmt.Errorf("write %s: %w", abs, writeErr) + } + return applied, failed, commit, nil } func countManual(plan []callerPatch) int { diff --git a/internal/mcp/safe_delete_propagate_test.go b/internal/mcp/safe_delete_propagate_test.go index bd09cf080..57416eae7 100644 --- a/internal/mcp/safe_delete_propagate_test.go +++ b/internal/mcp/safe_delete_propagate_test.go @@ -3,9 +3,11 @@ package mcp import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "testing" + "time" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -102,3 +104,132 @@ func TestPropagateDeletePlanAndApply(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(callerSrc), "Target()", "the call site should be removed") } + +// A propagate plan is built from one read of each caller line, and the apply +// re-reads the file later. A line edited or shifted in between must be +// refused rather than deleted by number, every refusal must be reported once, +// and a file with nothing left to remove must not be rewritten. +func TestPropagateRefusesStaleLines(t *testing.T) { + dir := t.TempDir() + write := func(name, src string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) + } + write("del.go", "package p\n\nfunc Target() {}\n") + write("edited.go", "package p\n\nfunc Edited() {\n\tTarget()\n}\n") + write("shifted.go", "package p\n\nfunc Shifted() {\n\tTarget()\n}\n") + write("gate.go", "package p\n\nfunc Gate() {\n\tTarget(\n\t)\n}\n") + + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + + var target *graph.Node + for _, n := range g.AllNodes() { + if n.Name == "Target" && n.Kind == graph.KindFunction { + target = n + } + } + require.NotNil(t, target) + ctx := context.Background() + plan := srv.buildPropagationPlan(ctx, g, target) + removable := 0 + for _, p := range plan { + if p.Action == "remove_line" { + removable++ + } + } + require.Equal(t, 2, removable, "edited.go and shifted.go each hold one standalone call: %+v", plan) + + // gate.go: a planned line whose removal breaks the file, and a planned + // line past the end of the same file. + gateAbs := filepath.Join(dir, "gate.go") + plan = append(plan, + callerPatch{File: "gate.go", Line: 4, CurrentText: "Target(", Action: "remove_line", abs: gateAbs}, + callerPatch{File: "gate.go", Line: 40, CurrentText: "Target()", Action: "remove_line", abs: gateAbs}, + ) + + // Concurrent edits after planning. + write("edited.go", "package p\n\nfunc Edited() {\n\tKeep()\n}\n") + write("shifted.go", "package p\n\nfunc Shifted() {\n\tprepare()\n\tTarget()\n}\n") + + before := map[string][]byte{} + stats := map[string]os.FileInfo{} + for _, name := range []string{"edited.go", "shifted.go", "gate.go"} { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + require.NoError(t, err) + info, err := os.Stat(path) + require.NoError(t, err) + before[name], stats[name] = data, info + } + + applied, failed, err := srv.applyRemoveLinePatches(ctx, plan) + require.NoError(t, err) + require.Empty(t, applied) + + got := map[string]string{} + for _, p := range failed { + key := fmt.Sprintf("%s:%d", p.File, p.Line) + _, dup := got[key] + require.False(t, dup, "patch %s reported twice in failed: %+v", key, failed) + got[key] = p.Reason + } + require.Equal(t, map[string]string{ + "edited.go:4": "line_changed", + "shifted.go:4": "line_changed", + "gate.go:4": "parse_errors", + "gate.go:40": "line_out_of_range", + }, got) + + for name, data := range before { + path := filepath.Join(dir, name) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(data), string(after), "%s must stay byte-identical", name) + info, err := os.Stat(path) + require.NoError(t, err) + require.True(t, os.SameFile(stats[name], info), "%s was rewritten although every patch was refused", name) + } +} + +// Every caller file is locked before any is written. A lock the apply cannot +// take, here one held past the request's deadline, must leave all of them as +// they were, not only the files the apply had not reached yet. +func TestPropagateWritesNothingWhenACallerLockFails(t *testing.T) { + dir := t.TempDir() + const src = "package p\n\nfunc C() {\n\tTarget()\n}\n" + for i := range 8 { + require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("c%d.go", i)), []byte(src), 0o644)) + } + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil) + + var plan []callerPatch + for i := range 8 { + name := fmt.Sprintf("c%d.go", i) + plan = append(plan, callerPatch{File: name, Line: 4, CurrentText: "Target()", Action: "remove_line", abs: filepath.Join(dir, name)}) + } + _, release, err := acquireMutationPath(context.Background(), plan[len(plan)-1].abs) + require.NoError(t, err) + defer release() + + // Map order picks the file a file-by-file apply would lock first, so + // repeat until such an apply would have written one. + for range 3 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + applied, _, err := srv.applyRemoveLinePatches(ctx, plan) + cancel() + require.Error(t, err) + require.Empty(t, applied) + for _, p := range plan { + data, err := os.ReadFile(p.abs) + require.NoError(t, err) + require.Equal(t, src, string(data), "%s was rewritten although the apply failed", p.File) + } + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f910840a4..f277b3103 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -131,6 +131,8 @@ func (sh *symbolHistory) All() map[string][]SymbolModification { // Server wraps the MCP server with Gortex-specific tools. type Server struct { + editAckMode config.EditAckMode + editAckConfigErr error // editOperations covers actual source-edit handler completions across this // Server's sessions/views, including handlers that outlive their caller. // Native filesystem stimuli remain harness-owned, not inferred from events. @@ -1487,6 +1489,8 @@ func prependUnique(slice []string, item string, maxLen int) []string { // MultiRepoOptions holds optional multi-repo components for the Server. // When nil or zero-valued, the server operates in single-repo mode. type MultiRepoOptions struct { + // EditAckMode is startup policy; empty inherits global config, then full. + EditAckMode config.EditAckMode MultiIndexer *indexer.MultiIndexer ConfigManager *config.ConfigManager // CheckoutLifecycle owns every side effect of tracking and forgetting a @@ -1952,6 +1956,17 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche s.watcher = watcher } + ackConfig := config.EditConfig{} + if len(opts) > 0 { + if opts[0].ConfigManager != nil { + ackConfig = opts[0].ConfigManager.Global().Edit + } + if opts[0].EditAckMode != "" { + ackConfig.Ack = opts[0].EditAckMode + } + } + s.editAckMode, s.editAckConfigErr = ackConfig.AckMode() + // Apply multi-repo options if provided. if len(opts) > 0 { o := opts[0] @@ -3772,6 +3787,11 @@ func (s *Server) SetWatcher(w watcherHistory) { s.watcherMu.Lock() s.watcher = w s.watcherMu.Unlock() + if sourceEvents, ok := w.(interface{ OnSourceEvent(func(string)) }); ok { + sourceEvents.OnSourceEvent(func(path string) { + pendingFileProjections.markSuspect(path) + }) + } // Register callback to track symbol modifications for // get_symbol_history AND fan stale_refs notifications to any diff --git a/internal/mcp/source_ack_mode_frontier_test.go b/internal/mcp/source_ack_mode_frontier_test.go new file mode 100644 index 000000000..a9a37d3bc --- /dev/null +++ b/internal/mcp/source_ack_mode_frontier_test.go @@ -0,0 +1,213 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditAckLocalDurableBatchDoesNotWaitForBackground(t *testing.T) { + s, root := ackModeServer(t, "local") + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + paths := []string{writeAtomicBatchFixture(t, root, "a.txt", "before\n"), writeAtomicBatchFixture(t, root, "b.txt", "before\n")} + published := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + complete := sync.OnceFunc(func() { + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + }) + joined := make(chan struct{}) + var receipt batchTransactionReceipt + var runErr error + go func() { + defer close(joined) + receipt, runErr = s.runBatchTransaction(t.Context(), []batchEditItem{atomicFileEdit(paths[0], "before", "after"), atomicFileEdit(paths[1], "before", "after")}, "local-batch") + }() + defer func() { + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned local durable batch did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-time.After(3 * time.Second): + t.Fatal("local durable batch did not admit") + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("edit.ack local durable batch waited for background publication") + } + require.NoError(t, runErr) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "pending", receipt.GraphStatus) + raw, err := json.Marshal(receipt) + require.NoError(t, err) + require.Contains(t, string(raw), `"ack_mode":"local"`) + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} + +type ackModeCancelWatcher struct { + mutationTestWatcher + cancel context.CancelFunc +} + +func (w ackModeCancelWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + w.cancel() + return ticket, err +} + +func TestEditAckLocalCancellationAfterCommitRetainsAdmission(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.SetWatcher(ackModeCancelWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, cancel: cancel}) + path := filepath.Join(root, "main.go") + request := editFileRequest(map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + result, err := s.handleEditFile(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "println(1)") + payload := extractTextResult(t, result) + require.Equal(t, "local", payload["ack_mode"]) + require.Equal(t, true, payload["reindex_pending"]) + require.NotEmpty(t, payload["reindex_receipt"]) +} + +type ackModeTierWatcher struct { + watchedTierFixture + scope string + admitted chan struct{} +} + +func (w *ackModeTierWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + if err != nil { + return nil, err + } + now := time.Now() + state := indexer.MutationTierState{Generation: ticket.Generation, AckAt: now, FileGraphAt: now} + if w.scope == "derived" { + state.RelationsAt = now + } + w.mu.Lock() + w.paths[path] = state + w.mu.Unlock() + close(w.admitted) + return ticket, nil +} + +func TestEditAckRequireFreshUsesRequestedTierBeforeWholePatch(t *testing.T) { + for _, scope := range []string{"file", "relations", "derived"} { + t.Run(scope, func(t *testing.T) { + s, root := ackModeServer(t, "local") + path := filepath.Join(root, "main.go") + published := make(chan indexer.MutationResult, 1) + watcher := &ackModeTierWatcher{watchedTierFixture: watchedTierFixture{mutationTestWatcher: mutationTestWatcher{done: published}, paths: make(map[string]indexer.MutationTierState)}, scope: scope, admitted: make(chan struct{})} + s.SetWatcher(watcher) + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.mutationReindexWait = time.Minute + waiting := make(chan struct{}, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { waiting <- struct{}{}; return true } + request := editFileRequest(map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json", "require_fresh": true, "freshness_scope": scope, "wait_deadline": time.Now().Add(5 * time.Second).Format(time.RFC3339Nano)}) + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var runErr error + go func() { defer close(joined); result, runErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned tier ACK did not join") + } + }() + select { + case <-watcher.admitted: + case <-time.After(3 * time.Second): + t.Fatal("tier ACK never admitted") + } + if scope != "file" { + select { + case <-waiting: + case <-time.After(time.Second): + t.Fatal("pending requested tier did not enter unlocked wait") + } + select { + case <-joined: + t.Fatal("file-graph stage incorrectly certified pending later tier") + default: + } + watcher.mu.Lock() + state := watcher.paths[path] + if scope == "relations" { + state.RelationsAt = time.Now() + } else { + state.DerivedAt = time.Now() + } + watcher.paths[path] = state + watcher.mu.Unlock() + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("requested published tier remained coupled to whole-patch completion") + } + require.NoError(t, runErr) + require.False(t, result.IsError, "%v", result.Content) + require.Contains(t, toolResultText(result), `"reindex_pending":true`) + require.NotContains(t, toolResultText(result), `"graph_status":"fresh"`) + }) + } +} + +func TestEditAckLocalMoveDoesNotClaimBackgroundWait(t *testing.T) { + s, _ := setupMoveInlineRepo(t, map[string]string{"pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n"}) + s.editAckMode = config.EditAckLocal + published := make(chan indexer.MutationResult, 2) + defer close(published) + s.SetWatcher(&renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)}) + s.mutationReindexWait = time.Minute + request := mcplib.CallToolRequest{} + request.Params.Name = "move_symbol" + request.Params.Arguments = map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + result, err := s.runAcknowledgedEdit(t.Context(), request, s.handleMoveSymbol) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + response := decodeFileOpsResult(t, result) + for _, raw := range response["file_observations"].([]any) { + id := raw.(map[string]any)["receipt"].(string) + status, ok := s.moveFileObservationPayload(t.Context(), id) + require.True(t, ok) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, map[string]string{"publish": "applicable", "reindex_wait": "not_applicable_fast_ack"}, status["acknowledgement_phase_applicability"], "a queued ticket is not evidence that local mode waited") + require.Nil(t, status["direct_reconcile"]) + require.Nil(t, status["read_assisted_reconcile"]) + } +} diff --git a/internal/mcp/source_ack_mode_test.go b/internal/mcp/source_ack_mode_test.go new file mode 100644 index 000000000..4b5409e17 --- /dev/null +++ b/internal/mcp/source_ack_mode_test.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "go.uber.org/zap" +) + +func ackModeServer(t *testing.T, mode string) (*Server, string) { + t.Helper() + s, root := setupTestServer(t) + file := filepath.Join(t.TempDir(), "config.yaml") + body := "" + if mode != "" { + body = "edit:\n ack: " + mode + "\n" + } + require.NoError(t, os.WriteFile(file, []byte(body), 0o600)) + cm, err := config.NewConfigManager(file) + require.NoError(t, err) + s = NewServer(s.engine, s.graph, s.indexer, nil, zap.NewNop(), nil, MultiRepoOptions{ConfigManager: cm}) + return s, root +} + +func TestEditAckLocalConfigurationReturnsBeforeQueuedPublication(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + request := editFileRequest(map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + go func() { defer close(joined); result, resultErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned local ACK did not join") + } + }() + select { + case <-watcher.announced: + case <-time.After(3 * time.Second): + t.Fatal("local ACK did not admit physical write") + } + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("explicit edit.ack=local waited for unpublished background work") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + text := toolResultText(result) + require.Contains(t, text, `"ack_mode":"local"`) + require.Contains(t, text, `"reindex_pending":true`) + require.NotContains(t, text, `"graph_status":"fresh"`) +} + +func TestEditAckDefaultAndExplicitFullWaitForActualPublication(t *testing.T) { + for _, mode := range []string{"", "full"} { + t.Run("mode="+mode, func(t *testing.T) { + s, root := ackModeServer(t, mode) + published := make(chan indexer.MutationResult, 1) + s.SetWatcher(mutationTestWatcher{done: published}) + s.mutationReindexWait = time.Minute + waiting := make(chan struct{}, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { waiting <- struct{}{}; return true } + complete := sync.OnceFunc(func() { published <- indexer.MutationResult{Reindexed: true}; close(published) }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + joined := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + request := editFileRequest(map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }", "format": "json"}) + go func() { defer close(joined); result, resultErr = s.runAcknowledgedEdit(ctx, request, s.handleEditFile) }() + defer func() { + cancel() + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned full ACK did not join") + } + }() + select { + case <-waiting: + case <-time.After(3 * time.Second): + t.Fatal("full ACK did not enter publication wait") + } + select { + case <-joined: + t.Fatal("full ACK claimed completion before actual publication") + default: + } + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("published full ACK did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.Contains(t, toolResultText(result), `"ack_mode":"full"`) + }) + } +} diff --git a/internal/mcp/source_ack_policy.go b/internal/mcp/source_ack_policy.go new file mode 100644 index 000000000..2125423fb --- /dev/null +++ b/internal/mcp/source_ack_policy.go @@ -0,0 +1,73 @@ +package mcp + +import ( + "context" + "time" + + "github.com/zzet/gortex/internal/config" +) + +// A value snapshot belongs to the source operation, including every physical +// file and publication admission in a frontier. Configuration cannot change it +// between writes or while an admitted ticket is completing. +type sourceAckPolicy struct { + owner *Server + mode config.EditAckMode + freshness requestFreshness +} + +type sourceAckPolicyKey struct{} + +func (s *Server) sourceAckPolicy(ctx context.Context) sourceAckPolicy { + if policy, ok := ctx.Value(sourceAckPolicyKey{}).(sourceAckPolicy); ok && policy.owner == s { + return policy + } + mode := s.editAckMode + if mode == "" { + mode = config.EditAckFull + } + return sourceAckPolicy{owner: s, mode: mode} +} + +func (p sourceAckPolicy) local() bool { + return p.mode == config.EditAckLocal && !p.freshness.requireFresh +} + +// Explicit tier waits reuse the single freshness producer. They never turn a +// file/relations stamp into whole-patch completion, and own no source locks. +func (s *Server) awaitSourceAckTier(ctx context.Context, policy sourceAckPolicy, paths []string, published <-chan struct{}) error { + scope := policy.freshness.scope + if scope == "" { + scope = "file" + } + deadline := policy.freshness.effectiveDeadline(time.Now(), ctx) + waitCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + label := s.freshnessFor(waitCtx, scope, nil, paths) + if label.state == "" || label.state == "failed" || !deadline.After(time.Now()) { + return editGateCheckoutACKSeed(ctx, s) + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + defer lap.finish() + if err := editGateACKSeed(ctx, s); err != nil { + return err + } + if !s.releaseDispatchSlotForWait(waitCtx, published) { + return nil + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + label = s.freshnessFor(waitCtx, scope, nil, paths) + if label.state == "" || label.state == "failed" { + return nil + } + select { + case <-waitCtx.Done(): + return nil + case <-ticker.C: + } + } +} diff --git a/internal/mcp/source_batch_frontier_wait_test.go b/internal/mcp/source_batch_frontier_wait_test.go new file mode 100644 index 000000000..4364c3083 --- /dev/null +++ b/internal/mcp/source_batch_frontier_wait_test.go @@ -0,0 +1,81 @@ +package mcp + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestDurableBatchReleasesWholeFrontierBeforePublicationWait(t *testing.T) { + published := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s := newAtomicBatchTestServer(t, watcher) + s.mutationReindexWait = time.Minute + root := t.TempDir() + paths := []string{writeAtomicBatchFixture(t, root, "a.txt", "before\n"), writeAtomicBatchFixture(t, root, "b.txt", "before\n")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if release != nil { + release() + } + waiting <- err + return true + } + complete := sync.OnceFunc(func() { + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + }) + joined := make(chan struct{}) + var receipt batchTransactionReceipt + var runErr error + go func() { + defer close(joined) + receipt, runErr = s.runBatchTransaction(t.Context(), []batchEditItem{atomicFileEdit(paths[0], "before", "after"), atomicFileEdit(paths[1], "before", "after")}, "unlocked-batch-frontier") + }() + defer func() { + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned durable batch did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-time.After(3 * time.Second): + t.Fatal("durable batch did not admit its complete file set") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "after\n", string(raw)) + } + select { + case err := <-waiting: + require.NoError(t, err, "durable batch retained its held paths across graph publication wait") + case <-time.After(time.Second): + t.Fatal("durable batch did not enter its shared publication wait") + } + complete() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("published durable batch did not join") + } + require.NoError(t, runErr) + require.Equal(t, "committed", receipt.Status) + require.Equal(t, "fresh", receipt.GraphStatus) + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} diff --git a/internal/mcp/source_bypass_commit_test.go b/internal/mcp/source_bypass_commit_test.go new file mode 100644 index 000000000..64b75de9c --- /dev/null +++ b/internal/mcp/source_bypass_commit_test.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/pathkey" +) + +func TestPropagatedSourceWriteHasCommitReceipt(t *testing.T) { + srv, _, root := setupDeleteServer(t) + path := filepath.Join(root, "caller.go") + ctx, note := withMutationCommitNote(t.Context()) + applied, failed, err := srv.applyRemoveLinePatches(ctx, []callerPatch{{ + File: "caller.go", Line: 4, CurrentText: "Target()", Action: "remove_line", abs: path, + }}) + require.NoError(t, err) + require.Empty(t, failed) + require.Len(t, applied, 1) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(current), "Target()", "prove that a real source write landed") + verdict := note.verdict() + require.Equal(t, mutationDiskCommitted, verdict.Status, "a source writer must publish the same disk receipt as the ordinary edit path") + require.Len(t, verdict.Snapshots, 1) + require.Equal(t, "caller.go", verdict.Snapshots[0].Path) + require.True(t, verdict.Snapshots[0].GraphRecorded) +} + +func TestOverlayDiskMergeUsesSourceCommitAuthority(t *testing.T) { + for _, deleted := range []bool{false, true} { + for _, cancelled := range []bool{false, true} { + t.Run(fmt.Sprintf("deleted_%t/cancelled_%t", deleted, cancelled), func(t *testing.T) { + srv, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + if cancelled { + cancel() + } + err = srv.diskWriteForMerge(ctx)(daemon.OverlayFile{Path: "main.go", Content: "package main\nfunc Merged() {}\n", Deleted: deleted, BaseSHA: gitBlobSHA(before)}) + if cancelled { + require.Error(t, err) + got, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, before, got, "refused merge must preserve bytes") + return + } + require.NoError(t, err) + if deleted { + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + } else { + got, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Contains(t, string(got), "func Merged()") + } + require.Equal(t, mutationDiskCommitted, note.verdict().Status) + require.Len(t, note.verdict().Snapshots, 1) + }) + } + } +} + +func TestMoveAndInlineSourceWritesHavePhysicalReceipts(t *testing.T) { + for _, tool := range []string{"move_symbol", "inline_symbol"} { + t.Run(tool, func(t *testing.T) { + srv, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/accessor.go": "package pkg\nfunc Value() int { return 7 }\n", + "pkg/caller.go": "package pkg\nfunc Use() int { return Value() }\n", + "pkg/target.go": "package pkg\nfunc Existing() {}\n", + }) + ctx, note := withMutationCommitNote(t.Context()) + req := editFileRequest(map[string]any{"id": "pkg/accessor.go::Value", "target_file": "pkg/target.go"}) + var result *mcp.CallToolResult + var err error + if tool == "move_symbol" { + result, err = srv.handleMoveSymbol(ctx, req) + } else { + result, err = srv.handleInlineSymbol(ctx, req) + } + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + accessor, err := os.ReadFile(filepath.Join(root, "pkg/accessor.go")) + require.NoError(t, err) + require.NotContains(t, string(accessor), "func Value()", "prove a real multi-file source operation landed") + require.Equal(t, mutationDiskCommitted, note.verdict().Status) + paths := make([]string, 0, len(note.verdict().Snapshots)) + for _, snapshot := range note.verdict().Snapshots { + paths = append(paths, snapshot.Path) + require.True(t, snapshot.GraphRecorded) + } + want := []string{"pkg/accessor.go", "pkg/caller.go"} + if tool == "move_symbol" { + want = append(want, "pkg/target.go") + } + require.ElementsMatch(t, want, paths, "every actual source effect must own exactly one physical receipt") + }) + } +} + +func TestPlannedSourceAdmissionPreservesChangedAndNewTargets(t *testing.T) { + for _, kind := range []string{"changed", "new_target", "cancelled", "held"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + first, second := filepath.Join(root, "first.go"), filepath.Join(root, "second.go") + require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) + require.NoError(t, os.WriteFile(second, []byte("before"), 0o644)) + plans := []plannedSource{{path: first, before: []byte("first"), existed: true}, {path: second, before: []byte("before"), existed: kind != "new_target"}} + if kind == "changed" { + require.NoError(t, os.WriteFile(second, []byte("concurrent"), 0o644)) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if kind == "cancelled" { + cancel() + } + ctx, release, err := lockPlannedSources(ctx, plans) + if kind == "held" { + require.NoError(t, err) + require.True(t, mutationPathAdmitted(ctx, pathkey.MutationKey(first))) + require.True(t, mutationPathAdmitted(ctx, pathkey.MutationKey(second))) + release() + require.False(t, mutationPathAdmitted(ctx, pathkey.MutationKey(first))) + } else { + require.Error(t, err) + require.Nil(t, release) + } + got, readErr := os.ReadFile(first) + require.NoError(t, readErr) + require.Equal(t, "first", string(got), "failure on another planned path must precede every write") + }) + } +} diff --git a/internal/mcp/source_frontier_seed_test.go b/internal/mcp/source_frontier_seed_test.go new file mode 100644 index 000000000..ee3a4cef2 --- /dev/null +++ b/internal/mcp/source_frontier_seed_test.go @@ -0,0 +1,78 @@ +//go:build gortex_edit_gate_seed + +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestEditGateACKSeedCoversWholeFrontierOnceAfterUnlock(t *testing.T) { + t.Setenv("GORTEX_EDIT_GATE_SEED", "ack-wait") + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + s.SetWatcher(&renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + paths := []string{filepath.Join(root, "caller.go"), filepath.Join(root, "target.go")} + entered := make(chan error, 1) + var calls atomic.Int32 + editGateSeedACKEntered = func() { + calls.Add(1) + check, stop := context.WithTimeout(t.Context(), 200*time.Millisecond) + _, release, err := acquireMutationPaths(check, paths) + stop() + if release != nil { + release() + } + select { + case entered <- err: + default: + } + } + t.Cleanup(func() { editGateSeedACKEntered = nil }) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + joined := make(chan struct{}) + go func() { defer close(joined); _, _ = s.runAcknowledgedEdit(ctx, request, s.handleRenameSymbol) }() + defer func() { + cancel() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Error("owned frontier seed did not join") + } + }() + select { + case err := <-entered: + require.NoError(t, err, "tag-only ACK seed retained physical path locks") + case <-joined: + t.Fatal("completed multi-file source frontier returned before ACK seed") + case <-time.After(3 * time.Second): + t.Fatal("multi-file source frontier never reached ACK seed") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "Renamed", "seed must follow every physical source effect") + } + cancel() + select { + case <-joined: + case <-time.After(3 * time.Second): + t.Fatal("cancelled frontier seed did not join") + } + require.EqualValues(t, 1, calls.Load(), "one operation cannot acquire a seed wait per file") +} diff --git a/internal/mcp/source_move_inline_frontier_test.go b/internal/mcp/source_move_inline_frontier_test.go new file mode 100644 index 000000000..c1da7ed9e --- /dev/null +++ b/internal/mcp/source_move_inline_frontier_test.go @@ -0,0 +1,166 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestMoveAndInlineAdmitWholeFrontierBeforeWaiting(t *testing.T) { + for _, tool := range []string{"move_symbol", "inline_symbol"} { + t.Run(tool, func(t *testing.T) { + files := map[string]string{"pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n"} + args := map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + if tool == "inline_symbol" { + files = map[string]string{"pkg/a.go": "package pkg\nfunc GetX(s *S) int { return s.X }\n", "pkg/b.go": "package pkg\nfunc Use() int { s := &S{X: 3}; return GetX(s) }\n", "pkg/types.go": "package pkg\ntype S struct { X int }\n"} + args = map[string]any{"id": "pkg/a.go::GetX", "format": "json"} + } + s, root := setupMoveInlineRepo(t, files) + s.ToolCallTimeout = -1 + s.mutationReindexWait = time.Minute + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + paths := []string{filepath.Join(root, "pkg", "a.go"), filepath.Join(root, "pkg", "b.go")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Name, request.Params.Arguments = tool, args + handler := s.boundToolHandler(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + if tool == "move_symbol" { + return s.handleMoveSymbol(ctx, req) + } + return s.handleInlineSymbol(ctx, req) + }) + ctx, cancel := context.WithCancel(t.Context()) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = handler(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned mutation did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("%s returned without admitting the committed source frontier", tool) + case <-time.After(3 * time.Second): + t.Fatal("mutation did not admit its committed source frontier") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NotEqual(t, files["pkg/"+filepath.Base(path)], string(raw), "all physical source effects must precede admission") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + select { + case err := <-waiting: + require.NoError(t, err, "path set must be released before passive graph wait") + case <-time.After(time.Second): + t.Fatal("mutation never entered passive wait") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published mutation did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + if tool == "move_symbol" { + response := decodeFileOpsResult(t, result) + for _, raw := range response["file_observations"].([]any) { + id := raw.(map[string]any)["receipt"].(string) + status, ok := s.moveFileObservationPayload(ctx, id) + require.True(t, ok) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, "unavailable_queued_publication", status["direct_reconcile_state"]) + require.Nil(t, status["direct_reconcile"]) + require.Nil(t, status["read_assisted_reconcile"]) + require.Equal(t, map[string]string{"publish": "applicable", "reindex_wait": "applicable"}, status["acknowledgement_phase_applicability"]) + } + } + }) + } +} + +func TestInlineSameFileKeepsCallerRewriteWhenDeletingCallee(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/a.go": "package pkg\ntype S struct { X int }\nfunc GetX(s *S) int { return s.X }\nfunc Use() int { s := &S{X: 3}; return GetX(s) }\n", + }) + result := callTool(t, s, "inline_symbol", map[string]any{"id": "pkg/a.go::GetX", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "pkg", "a.go")) + require.NoError(t, err) + require.NotContains(t, string(raw), "func GetX") + require.NotContains(t, string(raw), "return GetX(s)", "deleting the callee must not overwrite the caller rewrite in the same physical file") + require.Contains(t, string(raw), "return s.X") +} + +func TestMovePartialIOFailureStillAdmitsCommittedTarget(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "pkg/a.go": "package pkg\nfunc Foo() int { return 42 }\n", + "pkg/b.go": "package pkg\nfunc Bar() int { return 1 }\n", + }) + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Nanosecond + source := filepath.Join(root, "pkg", "a.go") + target := filepath.Join(root, "pkg", "b.go") + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.absPath == source { + // The target write already succeeded. Model a real independent + // directory replacement that makes the next atomic rename fail. + require.NoError(t, os.Remove(source)) + require.NoError(t, os.Mkdir(source, 0o755)) + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "pkg/a.go::Foo", "target_file": "pkg/b.go", "format": "json"} + result, err := s.handleMoveSymbol(t.Context(), request) + require.NoError(t, err) + require.True(t, result.IsError) + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{target}, admitted, "already committed source effects retain publication even when the next file fails") + default: + t.Fatal("partial failure abandoned the successfully committed target") + } + raw, err := os.ReadFile(target) + require.NoError(t, err) + require.Contains(t, string(raw), "func Foo") + require.EqualValues(t, 1, watcher.points.Load()) + require.Zero(t, watcher.sets.Load()) +} diff --git a/internal/mcp/source_mutation_delta.go b/internal/mcp/source_mutation_delta.go new file mode 100644 index 000000000..98025fba5 --- /dev/null +++ b/internal/mcp/source_mutation_delta.go @@ -0,0 +1,193 @@ +package mcp + +import ( + "bytes" + "fmt" + "github.com/zzet/gortex/internal/graph" + "reflect" + "sort" + "strings" + "unicode/utf8" +) + +type fileSymbolDelta struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Counts *fileSymbolDeltaCounts `json:"counts"` + Symbols []fileSymbolDeltaEntry `json:"symbols"` + Truncated bool `json:"truncated"` +} +type fileSymbolDeltaCounts struct { + Before int `json:"before"` + After int `json:"after"` + Added int `json:"added"` + Removed int `json:"removed"` + Changed int `json:"changed"` + TotalChanges int `json:"total_changes"` +} +type fileSymbolDeltaEntry struct { + Change string `json:"change"` + ID string `json:"id"` + Kind graph.NodeKind `json:"kind"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Signature string `json:"signature"` +} + +// buildFileSymbolDelta compares caller-owned complete symbol sets extracted from +// the supplied decoded bytes. baselineKnown is the caller's baseline authority; +// this pure helper cannot discover omitted nodes or authenticate an extraction. +// It validates the represented spans, but does not parse, read stores, or hash. +func buildFileSymbolDelta(before, after []byte, old, new []*graph.Node, baselineKnown bool) fileSymbolDelta { + unavailable := func(reason string) fileSymbolDelta { return fileSymbolDelta{State: "unavailable", Reason: reason} } + if !baselineKnown { + return unavailable("baseline_unknown") + } + previous, oldFile, err := fileSymbolSnapshots(before, old) + if err != nil { + return unavailable("before: " + err.Error()) + } + current, newFile, err := fileSymbolSnapshots(after, new) + if err != nil { + return unavailable("after: " + err.Error()) + } + if oldFile != "" && newFile != "" && oldFile != newFile { + return unavailable("mixed_file_evidence") + } + counts := &fileSymbolDeltaCounts{Before: len(previous), After: len(current)} + entries := make([]fileSymbolDeltaEntry, 0) + for id, next := range current { + prior, exists := previous[id] + change := "" + switch { + case !exists: + counts.Added++ + change = "added" + case !reflect.DeepEqual(prior.node, next.node) || !bytes.Equal(prior.body, next.body): + counts.Changed++ + change = "changed" + } + if change != "" { + entries = append(entries, fileSymbolPreview(next.node, change)) + } + } + for id, prior := range previous { + if _, exists := current[id]; !exists { + counts.Removed++ + entries = append(entries, fileSymbolPreview(prior.node, "removed")) + } + } + counts.TotalChanges = counts.Added + counts.Removed + counts.Changed + sort.Slice(entries, func(i, j int) bool { + if entries[i].ID != entries[j].ID { + return entries[i].ID < entries[j].ID + } + return entries[i].Change < entries[j].Change + }) + truncated := len(entries) > 20 + if truncated { + entries = entries[:20] + } + return fileSymbolDelta{State: "available", Counts: counts, Symbols: entries, Truncated: truncated} +} + +type fileSymbolSnapshot struct { + node *graph.Node + body []byte + start, end int +} + +func fileSymbolSnapshots(content []byte, nodes []*graph.Node) (map[string]fileSymbolSnapshot, string, error) { + if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { + return nil, "", fmt.Errorf("source_not_decoded_utf8") + } + offsets := []int{0} + for i, c := range content { + if c == '\n' { + offsets = append(offsets, i+1) + } + } + lineEnd := func(line int) int { + if line < len(offsets) { + return offsets[line] - 1 + } + return len(content) + } + result := make(map[string]fileSymbolSnapshot, len(nodes)) + file := "" + spans := make([]fileSymbolSnapshot, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + return nil, "", fmt.Errorf("nil_symbol") + } + if strings.TrimSpace(node.ID) == "" || node.Kind == "" || node.FilePath == "" { + return nil, "", fmt.Errorf("incomplete_symbol_identity") + } + if node.Stub || node.Origin != "" { + return nil, "", fmt.Errorf("nonlocal_symbol") + } + if _, exists := result[node.ID]; exists { + return nil, "", fmt.Errorf("duplicate_symbol_id") + } + if file == "" { + file = node.FilePath + } else if file != node.FilePath { + return nil, "", fmt.Errorf("mixed_file_evidence") + } + if signature, exists := node.Meta["signature"]; exists { + if _, ok := signature.(string); !ok { + return nil, "", fmt.Errorf("invalid_signature") + } + } + if node.StartLine < 1 || node.EndLine < node.StartLine || node.EndLine > len(offsets) { + return nil, "", fmt.Errorf("invalid_line_span") + } + start, end := offsets[node.StartLine-1], lineEnd(node.EndLine) + columns := node.StartColumn != 0 || node.EndColumn != 0 + if node.StartColumn < 0 || node.EndColumn < 0 { + return nil, "", fmt.Errorf("invalid_column_span") + } + if columns { + if node.StartColumn > lineEnd(node.StartLine)-start || node.EndColumn > end-offsets[node.EndLine-1] { + return nil, "", fmt.Errorf("invalid_column_span") + } + start += node.StartColumn + end = offsets[node.EndLine-1] + node.EndColumn + if start < len(content) && !utf8.RuneStart(content[start]) || end < len(content) && !utf8.RuneStart(content[end]) { + return nil, "", fmt.Errorf("column_splits_utf8") + } + } + if start >= end { + return nil, "", fmt.Errorf("empty_or_reversed_span") + } + snapshot := fileSymbolSnapshot{node: node, body: content[start:end], start: start, end: end} + spans = append(spans, snapshot) + result[node.ID] = snapshot + } + // Unknown columns mean a complete inclusive line range, not invented token + // offsets. Nested members can share their parent's lines. Equal or crossing + // ranges, even with columns, cannot establish distinct symbol ownership. + sort.Slice(spans, func(i, j int) bool { + if spans[i].start != spans[j].start { + return spans[i].start < spans[j].start + } + return spans[i].end > spans[j].end + }) + for i, span := range spans { + for _, other := range spans[i+1:] { + if other.start >= span.end { + break + } + strictlyContains := span.start <= other.start && span.end >= other.end && (span.start < other.start || span.end > other.end) + if !strictlyContains { + return nil, "", fmt.Errorf("ambiguous_symbol_spans") + } + } + } + return result, file, nil +} + +func fileSymbolPreview(node *graph.Node, change string) fileSymbolDeltaEntry { + signature, _ := node.Meta["signature"].(string) + return fileSymbolDeltaEntry{Change: change, ID: node.ID, Kind: node.Kind, StartLine: node.StartLine, EndLine: node.EndLine, Signature: signature} +} diff --git a/internal/mcp/source_mutation_delta_test.go b/internal/mcp/source_mutation_delta_test.go new file mode 100644 index 000000000..59a07ce07 --- /dev/null +++ b/internal/mcp/source_mutation_delta_test.go @@ -0,0 +1,208 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser/languages" + "reflect" + "testing" +) + +func deltaNode(id string, line int, signature string) *graph.Node { + return &graph.Node{ID: "x.go::" + id, Kind: graph.KindFunction, Name: id, FilePath: "x.go", StartLine: line, EndLine: line, Meta: map[string]any{"signature": signature}} +} +func TestFileSymbolDeltaActualBodyAndSignatureChanges(t *testing.T) { + before := []byte("func A() { return 1 }\nfunc Removed() {}\nfunc Sig(x int) {}\n") + after := []byte("func A() { return 2 }\nfunc Added() {}\nfunc Sig(x string) {}\n") + old := []*graph.Node{deltaNode("A", 1, "func A()"), deltaNode("Removed", 2, "func Removed()"), deltaNode("Sig", 3, "func Sig(x int)")} + new := []*graph.Node{deltaNode("Sig", 3, "func Sig(x string)"), deltaNode("Added", 2, "func Added()"), deltaNode("A", 1, "func A()")} + oldBytes, _ := json.Marshal(old) + newBytes, _ := json.Marshal(new) + bcopy := bytes.Clone(before) + acopy := bytes.Clone(after) + d := buildFileSymbolDelta(before, after, old, new, true) + want := &fileSymbolDeltaCounts{Before: 3, After: 3, Added: 1, Removed: 1, Changed: 2, TotalChanges: 4} + if d.State != "available" || !reflect.DeepEqual(d.Counts, want) { + t.Fatalf("missing actual source delta: %+v", d) + } + ids := []string{} + for _, s := range d.Symbols { + ids = append(ids, s.ID+":"+s.Change) + } + if !reflect.DeepEqual(ids, []string{"x.go::A:changed", "x.go::Added:added", "x.go::Removed:removed", "x.go::Sig:changed"}) { + t.Fatalf("unstable preview: %v", ids) + } + ob, _ := json.Marshal(old) + nb, _ := json.Marshal(new) + if !bytes.Equal(ob, oldBytes) || !bytes.Equal(nb, newBytes) || !bytes.Equal(before, bcopy) || !bytes.Equal(after, acopy) { + t.Fatal("mutated caller evidence") + } + new[0].Meta["signature"] = "mutated" + if d.Symbols[3].Signature != "func Sig(x string)" { + t.Fatal("preview aliases input metadata") + } +} +func TestFileSymbolDeltaCapDoesNotCapCounts(t *testing.T) { + var source bytes.Buffer + var nodes []*graph.Node + for i := 29; i >= 0; i-- { + fmt.Fprintln(&source, "func Symbol() {}") + nodes = append(nodes, deltaNode(fmt.Sprintf("S%02d", i), 30-i, "func Symbol()")) + } + d := buildFileSymbolDelta(nil, source.Bytes(), nil, nodes, true) + if d.Counts == nil || d.Counts.Added != 30 || d.Counts.TotalChanges != 30 || len(d.Symbols) != 20 || !d.Truncated { + t.Fatalf("preview lost full totals: %+v", d) + } + for i, s := range d.Symbols { + if s.ID != fmt.Sprintf("x.go::S%02d", i) { + t.Fatalf("not ID ordered: %+v", d.Symbols) + } + } + removed := buildFileSymbolDelta(source.Bytes(), nil, nodes, nil, true) + if removed.Counts == nil || removed.Counts.Removed != 30 || removed.Symbols[0].StartLine != 30 { + t.Fatalf("removed preview did not retain OLD: %+v", removed) + } +} +func TestFileSymbolDeltaUnavailableEvidence(t *testing.T) { + source := []byte("func A() {}\n") + for _, mode := range []string{"unknown", "nil", "duplicate", "mixed", "range", "columns", "signature", "invalid_utf8", "ambiguous"} { + t.Run(mode, func(t *testing.T) { + old := []*graph.Node{deltaNode("A", 1, "func A()")} + new := []*graph.Node{deltaNode("A", 1, "func A()")} + known := true + raw := bytes.Clone(source) + switch mode { + case "unknown": + known = false + case "nil": + new[0] = nil + case "duplicate": + new = append(new, new[0]) + case "mixed": + new[0].FilePath = "other.go" + case "range": + new[0].EndLine = 99 + case "columns": + new[0].EndColumn = 999 + case "signature": + new[0].Meta["signature"] = 12 + case "invalid_utf8": + raw = []byte{255} + case "ambiguous": + new = append(new, deltaNode("B", 1, "func B()")) + } + d := buildFileSymbolDelta(source, raw, old, new, known) + if d.State != "unavailable" || d.Counts != nil || d.Reason == "" || len(d.Symbols) != 0 { + t.Fatalf("fabricated evidence: %+v", d) + } + }) + } +} +func TestFileSymbolDeltaLineShiftIsChanged(t *testing.T) { + a := deltaNode("A", 1, "func A()") + b := deltaNode("A", 2, "func A()") + d := buildFileSymbolDelta([]byte("func A() {}\n"), []byte("\nfunc A() {}\n"), []*graph.Node{a}, []*graph.Node{b}, true) + if d.Counts == nil || d.Counts.Changed != 1 || d.Symbols[0].StartLine != 2 { + t.Fatalf("full node changes suppressed: %+v", d) + } + stable := buildFileSymbolDelta([]byte("func A() {}\n"), []byte("func A() {}\n"), []*graph.Node{a}, []*graph.Node{a}, true) + if stable.Counts == nil || stable.Counts.TotalChanges != 0 || len(stable.Symbols) != 0 { + t.Fatalf("unchanged input: %+v", stable) + } +} + +func TestFileSymbolDeltaPreciseColumnsAndUTF8(t *testing.T) { + left := deltaNode("Left", 1, "") + left.StartColumn = 0 + left.EndColumn = 3 + right := deltaNode("Right", 1, "") + right.StartColumn = 4 + right.EndColumn = 7 + d := buildFileSymbolDelta([]byte("abc def"), []byte("abc xyz"), []*graph.Node{left, right}, []*graph.Node{left, right}, true) + if d.Counts == nil || d.Counts.Changed != 1 || d.Symbols[0].ID != "x.go::Right" { + t.Fatalf("precise sibling ranges conflated: %+v", d) + } + split := deltaNode("UTF8", 1, "") + split.StartColumn = 1 + split.EndColumn = 2 + bad := buildFileSymbolDelta(nil, []byte("é"), nil, []*graph.Node{split}, true) + if bad.Counts != nil || bad.Reason == "" { + t.Fatalf("split Unicode rune accepted: %+v", bad) + } +} +func TestFileSymbolDeltaNestedAndCrossingUnknownColumns(t *testing.T) { + parent := deltaNode("Parent", 1, "") + parent.EndLine = 3 + child := deltaNode("Child", 2, "") + data := []byte("parent\nchild\nend\n") + d := buildFileSymbolDelta(data, data, []*graph.Node{parent, child}, []*graph.Node{child, parent}, true) + if d.Counts == nil || d.Counts.TotalChanges != 0 { + t.Fatalf("valid containment rejected: %+v", d) + } + crossing := deltaNode("Crossing", 2, "") + crossing.EndLine = 4 + data = []byte("parent\nchild\nend\ntail\n") + d = buildFileSymbolDelta(data, data, []*graph.Node{parent, crossing}, []*graph.Node{parent, crossing}, true) + if d.Counts != nil || d.Reason == "" { + t.Fatalf("crossing unknown spans accepted: %+v", d) + } +} +func TestFileSymbolDeltaUsesActualDecodedGoExtractorSpans(t *testing.T) { + before := []byte("package demo\nfunc A() int {\n return 1\n}\nfunc B() {}\n") + after := []byte("package demo\nfunc A() int {\n return 2\n}\nfunc B() {}\n") + extractor := languages.NewGoExtractor() + old, err := extractor.Extract("x.go", before) + if err != nil { + t.Fatal(err) + } + defer old.ReleaseTree() + new, err := extractor.Extract("x.go", after) + if err != nil { + t.Fatal(err) + } + defer new.ReleaseTree() + // Production caller owns symbol-set filtering; this fixture explicitly + // chooses the complete function set and keeps the actual extracted nodes. + symbols := func(nodes []*graph.Node) []*graph.Node { + var out []*graph.Node + for _, n := range nodes { + if n.Kind == graph.KindFunction { + out = append(out, n) + } + } + return out + } + d := buildFileSymbolDelta(before, after, symbols(old.Nodes), symbols(new.Nodes), true) + if d.Counts == nil || d.Counts.Before != 2 || d.Counts.After != 2 || d.Counts.Changed != 1 || len(d.Symbols) != 1 || d.Symbols[0].ID != "x.go::A" { + t.Fatalf("actual extractor body edit missed: %+v", d) + } +} +func TestFileSymbolDeltaFullNodeMetadataChanges(t *testing.T) { + old := deltaNode("A", 1, "") + new := deltaNode("A", 1, "") + new.Meta["visibility"] = "exported" + d := buildFileSymbolDelta([]byte("func A() {}"), []byte("func A() {}"), []*graph.Node{old}, []*graph.Node{new}, true) + if d.Counts == nil || d.Counts.Changed != 1 { + t.Fatalf("full node metadata difference suppressed: %+v", d) + } +} + +func TestFileSymbolDeltaPreciseRangesStillRejectAmbiguousOwnership(t *testing.T) { + for _, crossing := range []bool{false, true} { + left := deltaNode("Left", 1, "") + left.EndColumn = 7 + right := deltaNode("Right", 1, "") + right.EndColumn = 7 + if crossing { + left.EndColumn = 5 + right.StartColumn = 3 + right.EndColumn = 7 + } + d := buildFileSymbolDelta(nil, []byte("abcdefg"), nil, []*graph.Node{left, right}, true) + if d.Counts != nil || d.State != "unavailable" { + t.Fatalf("ambiguous precise ownership accepted crossing=%v: %+v", crossing, d) + } + } +} diff --git a/internal/mcp/source_mutation_frontier.go b/internal/mcp/source_mutation_frontier.go new file mode 100644 index 000000000..3c5a7d760 --- /dev/null +++ b/internal/mcp/source_mutation_frontier.go @@ -0,0 +1,229 @@ +package mcp + +import ( + "context" + "errors" + "path/filepath" + + "github.com/zzet/gortex/internal/pathkey" + + "github.com/zzet/gortex/internal/viewmetrics" +) + +// Capture the selected checkout's exact per-file write fact before a later +// commit updates the request's last-write state. No lease or path lock is new. +type sourceMutationTarget struct { + key string + path string + checkout *checkoutMutationState +} + +func captureSourceMutationTarget(ctx context.Context, path string) sourceMutationTarget { + target := sourceMutationTarget{path: path, key: pathkey.MutationKey(path)} + if state := checkoutMutationFromContext(ctx); state != nil { + copy := *state + target.checkout = © + } + return target +} + +// Admit the complete committed frontier while its original path set is held. +// A partial/error response cannot be retried pointwise: some files may already +// be admitted. Only the explicit nil-map/no-admission result permits fallback. +func (s *Server) admitSourceMutationFrontier(ctx context.Context, targets []sourceMutationTarget) []sourceMutationAdmission { + admissions := make([]sourceMutationAdmission, len(targets)) + policy := s.sourceAckPolicy(ctx) + defer func() { + for i := range admissions { + admissions[i].policy = policy + } + }() + if len(targets) > 1 && checkoutMutationFromContext(ctx) == nil { + if scheduler, ok := s.currentWatcher().(mutationSetScheduler); ok { + paths := make([]string, len(targets)) + for i, target := range targets { + paths[i] = target.path + } + lap := s.beginAcknowledgementLap(ctx, editAckTicketAdmission) + tickets, err := scheduler.EnqueueFileMutations(context.WithoutCancel(ctx), paths) + lap.finish() + if err == nil && tickets != nil { + for _, path := range paths { + ticket := tickets[filepath.Clean(path)] + if ticket == nil || ticket.Done == nil || filepath.Clean(ticket.Path) != filepath.Clean(path) { + err = errors.New("mutation set scheduler returned incomplete or mismatched coverage") + break + } + } + } + if err != nil { + for i := range admissions { + admissions[i].immediate.Err = err + } + return admissions + } + if tickets != nil { + for i, path := range paths { + ticket := tickets[filepath.Clean(path)] + pin := handoffRequestView(ctx, viewmetrics.HandoffFileMutation) + receipt := s.trackMutationTicket(ticket) + receipt.pinView(pin) + s.bindReadRepairAdmission(path, ticket.PublicationRecord, receipt) + admissions[i].receipt = receipt + } + return admissions + } + } + } + for i, target := range targets { + admissionCtx := ctx + if target.checkout != nil { + admissionCtx = context.WithValue(ctx, checkoutMutationContextKey{}, target.checkout) + } + admissions[i] = s.admitSourceMutations(admissionCtx, target.path) + } + return admissions +} + +// Call only after every source path has been released. Inline compatibility +// work completes before returning dispatch capacity; queued work shares one +// parent deadline, one dispatch handoff and the existing receipt identities. +func (s *Server) awaitSourceMutationFrontier(ctx context.Context, admissions []sourceMutationAdmission) []mutationReindexOutcome { + waitCtx, cancel := context.WithTimeout(ctx, s.mutationWaitDuration()) + defer cancel() + outcomes := make([]mutationReindexOutcome, len(admissions)) + var files []batchTransactionFile + for i, admission := range admissions { + if admission.receipt == nil { + if admission.inlinePath == "" && admission.checkout == nil { + outcomes[i] = admission.immediate + } else { + outcomes[i] = s.awaitSourceMutations(withoutDispatchSlotRelease(waitCtx), admission) + } + } else { + files = append(files, batchTransactionFile{Path: admission.receipt.path, ReindexReceipt: admission.receipt.id}) + } + } + if len(files) == 0 { + // A selected checkout fast-ACKs one admitted operation. Even when + // it touched many files, the tagged legacy regression waits once. + for _, outcome := range outcomes { + if outcome.Err == nil && outcome.Receipt != "" { + err := editGateCheckoutACKSeed(ctx, s) + if err != nil { + for i := range outcomes { + if outcomes[i].Receipt != "" { + outcomes[i].Err = err + } + } + } + break + } + } + return outcomes + } + resumeIntent := suspendSourceMutationWriteIntent(ctx) + defer resumeIntent() + if len(admissions) > 0 && admissions[0].policy.freshness.requireFresh { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + err := s.awaitSourceAckTier(ctx, admissions[0].policy, paths, s.batchReceiptsPublished(ctx, files)) + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + if err != nil { + outcomes[i].Err = err + } + } + } + return outcomes + } + if len(admissions) > 0 && admissions[0].policy.local() { + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + } + } + if err := editGateCheckoutACKSeed(ctx, s); err != nil { + for i := range outcomes { + if admissions[i].receipt != nil { + outcomes[i].Err = err + } + } + } + return outcomes + } + lap := s.beginAcknowledgementLap(ctx, editAckReindexWait) + if err := editGateACKSeed(ctx, s); err != nil { + lap.finish() + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + outcomes[i].Err = err + } + } + return outcomes + } + + if s.releaseDispatchSlotForWait(waitCtx, s.batchReceiptsPublished(waitCtx, files)) { + wait: + for _, admission := range admissions { + if admission.receipt != nil { + select { + case <-admission.receipt.done: + case <-waitCtx.Done(): + break wait + } + } + } + } + lap.finish() + for i, admission := range admissions { + if admission.receipt != nil { + outcomes[i] = admission.receipt.outcome(true) + if !outcomes[i].Pending { + outcomes[i].Receipt = "" + } + } + } + return outcomes +} + +// An operation may rewrite one file more than once (propagated callsites then +// a definition). Only its final physical version can own a publication ticket. +// Intermediate physical receipts remain historical, without invented graph +// success for bytes no publication actually read. +type committedSourceMutation struct { + target sourceMutationTarget + record *mutationCommitRecord +} + +func captureCommittedSourceMutation(ctx context.Context, path string, record *mutationCommitRecord) committedSourceMutation { + return committedSourceMutation{target: captureSourceMutationTarget(ctx, path), record: record} +} + +func (s *Server) completeCommittedSourceMutations(ctx context.Context, writes []committedSourceMutation, releasePaths func()) ([]committedSourceMutation, []mutationReindexOutcome) { + final := make([]committedSourceMutation, 0, len(writes)) + positions := make(map[string]int, len(writes)) + for _, write := range writes { + if at, ok := positions[write.target.key]; ok { + final[at] = write + } else { + positions[write.target.key] = len(final) + final = append(final, write) + } + } + targets := make([]sourceMutationTarget, len(final)) + for i, write := range final { + targets[i] = write.target + } + admissions := s.admitSourceMutationFrontier(ctx, targets) + releasePaths() + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, outcome := range outcomes { + final[i].record.recordGraph(outcome) + } + return final, outcomes +} diff --git a/internal/mcp/source_mutation_frontier_test.go b/internal/mcp/source_mutation_frontier_test.go new file mode 100644 index 000000000..baf2055aa --- /dev/null +++ b/internal/mcp/source_mutation_frontier_test.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +type sourceFrontierCoverageWatcher struct { + mutationTestWatcher + partial bool + points int +} + +func (w *sourceFrontierCoverageWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + w.points++ + return w.mutationTestWatcher.EnqueueToolMutation(ctx, path) +} + +func (w *sourceFrontierCoverageWatcher) EnqueueFileMutations(ctx context.Context, paths []string) (map[string]*indexer.MutationTicket, error) { + if !w.partial { + return nil, nil // Explicitly no file was admitted. + } + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, paths[0]) + return map[string]*indexer.MutationTicket{paths[0]: ticket}, err +} + +func TestSourceMutationFrontierNeverDuplicatesPartialAdmission(t *testing.T) { + s := newMutationServer() + watcher := &sourceFrontierCoverageWatcher{partial: true} + s.SetWatcher(watcher) + root := t.TempDir() + targets := []sourceMutationTarget{{path: filepath.Join(root, "a.go")}, {path: filepath.Join(root, "b.go")}} + admissions := s.admitSourceMutationFrontier(t.Context(), targets) + require.Len(t, admissions, 2) + for _, admission := range admissions { + require.ErrorContains(t, admission.immediate.Err, "coverage") + require.Nil(t, admission.receipt) + require.Empty(t, admission.inlinePath) + } + require.Zero(t, watcher.points, "an incomplete admitted frontier must never be admitted again pointwise") +} + +func TestSourceMutationFrontierNilSetFallsBackOnce(t *testing.T) { + s := newMutationServer() + watcher := &sourceFrontierCoverageWatcher{} + s.SetWatcher(watcher) + root := t.TempDir() + targets := []sourceMutationTarget{{path: filepath.Join(root, "a.go")}, {path: filepath.Join(root, "b.go")}} + admissions := s.admitSourceMutationFrontier(t.Context(), targets) + require.Len(t, admissions, 2) + for _, admission := range admissions { + require.NotNil(t, admission.receipt) + <-admission.receipt.done + } + require.Equal(t, 2, watcher.points) +} + +func TestSourceMutationFrontierSharesDeadlineAndOneDispatchHandoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s := newMutationServer() + s.mutationReindexWait = 100 * time.Millisecond + first := &mutationReceipt{id: "first", done: make(chan struct{})} + second := &mutationReceipt{id: "second", done: make(chan struct{})} + s.mutationReceipts.Store(first.id, first) + s.mutationReceipts.Store(second.id, second) + calls := 0 + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { calls++; return true } + time.AfterFunc(60*time.Millisecond, func() { + first.mu.Lock() + first.completed = true + first.result = indexer.MutationResult{Reindexed: true} + first.mu.Unlock() + close(first.done) + }) + start := time.Now() + outcomes := s.awaitSourceMutationFrontier(t.Context(), []sourceMutationAdmission{{receipt: first}, {receipt: second}}) + require.Equal(t, 100*time.Millisecond, time.Since(start), "the second file must use the original deadline, not another wait allowance") + require.Equal(t, 1, calls) + require.True(t, outcomes[0].Reindexed) + require.False(t, outcomes[0].Pending) + require.True(t, outcomes[1].Pending) + require.Equal(t, second.id, outcomes[1].Receipt) + }) +} diff --git a/internal/mcp/source_projection_ack_suspect_test.go b/internal/mcp/source_projection_ack_suspect_test.go new file mode 100644 index 000000000..ebf26284d --- /dev/null +++ b/internal/mcp/source_projection_ack_suspect_test.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func committedProjectionAckFixture(t *testing.T) (string, *projectionEntry, *mutationCommitRecord) { + t.Helper() + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc SameOne() {}", "format": "json"}) + require.False(t, result.IsError) + response := extractTextResult(t, result) + requireMutationFileViewState(t, response, "projected") + entry := liveEntry(pendingFileProjections, path) + require.NotNil(t, entry) + record, ok := s.mutationCommits.byReceipt(response["mutation_receipt"].(string)) + require.True(t, ok) + return path, entry, record +} + +func TestMutationAckSuspectCheckPreservesNextLookupInvalidation(t *testing.T) { + path, entry, record := committedProjectionAckFixture(t) + before, err := os.ReadFile(path) + require.NoError(t, err) + changed := strings.Replace(string(before), "SameOne", "SameTwo", 1) + require.NotEqual(t, string(before), changed) + require.Len(t, changed, len(before)) + require.NoError(t, os.WriteFile(path, []byte(changed), 0o644)) + require.NoError(t, os.Chtimes(path, entry.disk.info.ModTime(), entry.disk.info.ModTime())) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + require.True(t, entry.disk.matches(stamp), "the external change must defeat the stat fast path") + pendingFileProjections.markSuspect(path) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + suspectAfterAck := entry.suspect.Load() + next, lookupErr := pendingFileProjections.lookup(path) + assert.ErrorIs(t, lookupErr, errProjectionExternalWrite, "an ACK check must not consume the only invalidation witness") + assert.Nil(t, next, "the next read must never receive the stale SameOne graph") + assert.True(t, suspectAfterAck, "receipt rendering must leave hash revalidation to the real lookup") +} + +func TestMutationAckRejectsSuspectReplacementWithoutFollowingIt(t *testing.T) { + for _, kind := range []string{"large", "symlink", "directory"} { + t.Run(kind, func(t *testing.T) { + path, entry, record := committedProjectionAckFixture(t) + switch kind { + case "large": + require.NoError(t, os.WriteFile(path, make([]byte, (1<<20)+1), 0o644)) + case "directory": + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Mkdir(path, 0o755)) + case "symlink": + other := filepath.Join(filepath.Dir(path), "other.go") + require.NoError(t, os.WriteFile(other, []byte("private replacement must not be read"), 0o644)) + require.NoError(t, os.Remove(path)) + if err := os.Symlink(other, path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + pendingFileProjections.markSuspect(path) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + require.True(t, entry.suspect.Load(), "an ACK may stat/refuse, but it must not read or clear a suspect file") + require.Same(t, entry, liveEntry(pendingFileProjections, path), "receipt rendering must not alter projection ownership") + }) + } +} + +func TestMutationAckCannotCreditReplacedEntryWithMatchingDiskStamp(t *testing.T) { + _, entry, record := committedProjectionAckFixture(t) + // A modeled COW replacement shares immutable content and disk metadata; + // pointer ownership, rather than equal bytes or sequence arithmetic, fences + // the old receipt. This is not a second physical write or timing sample. + replacement := entry.committedCopy(entry.commitSeq+1, entry.disk) + require.NoError(t, pendingFileProjections.publish(replacement)) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + require.Same(t, replacement, liveEntry(pendingFileProjections, entry.absPath)) + require.False(t, replacement.suspect.Load()) +} diff --git a/internal/mcp/source_projection_admission_test.go b/internal/mcp/source_projection_admission_test.go new file mode 100644 index 000000000..ca4590ba5 --- /dev/null +++ b/internal/mcp/source_projection_admission_test.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestToolCommitPublishesExactProjectionBeforeGraphCompletion(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + before := parser.ParseCount() + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc ProjectedAtCommit() {}", "format": "json"}) + parses := parser.ParseCount() - before + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + require.Equal(t, true, payload["reindex_pending"]) + require.Equal(t, map[string]any{"file": "fresh"}, payload["tier_state"], "the physical projection supplies file exactness, but an unstamped ticket cannot fabricate graph tier evidence") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "ProjectedAtCommit") + entry, err := pendingFileProjections.lookup(path) + require.NoError(t, err) + require.NotNil(t, entry, "physical commit must admit its exact file projection before a passive graph wait can end") + require.Positive(t, entry.commitSeq) + require.EqualValues(t, 1, parses, "a clean committed candidate shares one native parse with its syntax gate") + require.NotNil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::ProjectedAtCommit")) + require.Nil(t, s.graph.GetNode("main.go::ProjectedAtCommit"), "file projection is not graph publication") + requireMutationFileViewState(t, payload, "projected") + require.Equal(t, true, payload["readback_exact"]) +} + +type sourceProjectionBlockingExtractor struct { + base parser.Extractor + active atomic.Bool + once sync.Once + entered, release, finished chan struct{} +} + +func (e *sourceProjectionBlockingExtractor) Language() string { return "go" } +func (e *sourceProjectionBlockingExtractor) Extensions() []string { return []string{".go"} } +func (e *sourceProjectionBlockingExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.active.Load() { + e.once.Do(func() { close(e.entered) }) + <-e.release + defer close(e.finished) + } + return e.base.Extract(path, raw) +} + +func TestMutationCommitRechecksCancellationAndIdentityAfterProjection(t *testing.T) { + for _, mode := range []string{"cancel", "retarget"} { + t.Run(mode, func(t *testing.T) { + blocker := &sourceProjectionBlockingExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(blocker) + s, root := setupTestServerWithRegistry(t, registry) + var unblock sync.Once + t.Cleanup(func() { unblock.Do(func() { close(blocker.release) }) }) + path := filepath.Join(root, "main.go") + var alias, other string + if mode == "retarget" { + original := filepath.Join(root, "original") + other = filepath.Join(root, "other") + alias = filepath.Join(root, "selected") + for _, dir := range []string{original, other} { + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package p\nfunc Untouched() {}\n"), 0o644)) + } + if err := os.Symlink(original, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + path = filepath.Join(alias, "main.go") + } + before, err := os.ReadFile(path) + require.NoError(t, err) + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx, release, err := acquireMutationPath(parent, path) + require.NoError(t, err) + defer release() + blocker.active.Store(true) + done := make(chan error, 1) + go func() { + _, err := s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, []byte("package p\nfunc BlockedProjection() {}\n"), 0o644) + done <- err + }() + select { + case <-blocker.entered: + case <-time.After(2 * time.Second): + t.Fatal("candidate projection did not enter") + } + if mode == "cancel" { + cancel() + } else { + require.NoError(t, os.Remove(alias)) + require.NoError(t, os.Symlink(other, alias)) + } + unblock.Do(func() { close(blocker.release) }) + select { + case err := <-done: + require.ErrorIs(t, err, errMutationNotApplied) + case <-time.After(2 * time.Second): + t.Fatal("refused source commit did not return") + } + select { + case <-blocker.finished: + case <-time.After(2 * time.Second): + t.Fatal("owned projection extraction did not finish") + } + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, current, "no bytes may change after cancellation or held-path identity loss during preparation") + }) + } +} + +func TestRejectedProjectionCannotEscapeThroughMutationLedger(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + pendingFileProjections.enabled = false + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + s.SetWatcher(mutationTestWatcher{}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 2 }", "format": "json"}) + require.False(t, result.IsError) + payload := extractTextResult(t, result) + requireMutationFileViewState(t, payload, "unavailable") + require.Equal(t, false, payload["readback_exact"]) + require.Equal(t, "disabled", payload["file_reason"]) + require.Empty(t, pendingFileProjections.snap.Load().entries) + record, ok := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, ok) + require.Nil(t, record.fileProjection.Value(), "a refused graph must never be retained by a physical receipt") +} + +func TestPreparedProjectionEachPhysicalCommitHasImmutableEntry(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + raw := []byte("package main\nfunc SameBytes() {}\n") + prepared, _ := s.buildFileProjection(ctx, path, raw) + require.NotNil(t, prepared.entry, prepared.refusal) + operation := sourceMutationOperation{kind: sourceWrite, projection: prepared} + _, err = s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, raw, 0o644, operation) + require.NoError(t, err) + first := liveEntry(pendingFileProjections, path) + require.NotNil(t, first) + sequence, disk := first.commitSeq, first.disk + _, err = s.commitFileMutation(ctx, "write_file", "", "", "main.go", path, raw, 0o644, operation) + require.NoError(t, err) + second := liveEntry(pendingFileProjections, path) + require.NotSame(t, first, second) + require.Greater(t, second.commitSeq, sequence) + require.Equal(t, sequence, first.commitSeq, "old pinned snapshots must remain immutable") + require.True(t, first.disk.matches(disk)) + require.Zero(t, prepared.entry.commitSeq, "a reusable private preparation never becomes a published mutable entry") +} diff --git a/internal/mcp/source_projection_commit.go b/internal/mcp/source_projection_commit.go new file mode 100644 index 000000000..a44648b2d --- /dev/null +++ b/internal/mcp/source_projection_commit.go @@ -0,0 +1,137 @@ +package mcp + +import ( + "context" + "path/filepath" + "weak" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +// Only the bounded registry strongly owns accepted file graphs. A physical +// receipt retains a weak link and scalars, so rejected/retired projections +// cannot escape the registry's global memory allowance through the ledger. +func (s *Server) publishCommittedFileProjection(ctx context.Context, record *mutationCommitRecord, prepared *preparedFileProjection) { + if prepared == nil { + return + } + reason := prepared.refusal + entry := prepared.entry + exact := false + if entry != nil { + reason = "readback_unavailable" + if pathkey.MutationKey(entry.absPath) == prepared.key { + before, err := statDiskStamp(entry.absPath) + if entry.tombstone { + // Absence is exact readback of this held-path unlink, not proof + // that any background generation has published the deletion. + if err == nil && before.info == nil && pathkey.MutationKey(entry.absPath) == prepared.key { + exact = true + reason = publishPhysicalProjection(record, entry, before) + } + } else if err == nil && before.info != nil && before.info.Mode().IsRegular() { + files, err := source.NewFilesystemSource(filepath.Dir(entry.absPath)) + if err == nil { + raw, _, readErr := files.ReadRegularFile(ctx, filepath.Base(entry.absPath), 1<<20) + files.Close() + after, statErr := statDiskStamp(entry.absPath) + if readErr == nil && statErr == nil && before.matches(after) && blake3.Sum256(raw) == prepared.rawHash && pathkey.MutationKey(entry.absPath) == prepared.key { + exact = true + reason = publishPhysicalProjection(record, entry, after) + } else if readErr == nil { + reason = "readback_changed" + } + } + } + } else { + reason = "source_identity_changed" + } + } + record.mu.Lock() + record.fileReason = reason + record.readbackExact = exact + record.mu.Unlock() +} + +func (record *mutationCommitRecord) attachFileProjection(response map[string]any) { + record.mu.RLock() + sequence, committed := record.commitSeq, record.disk == mutationDiskCommitted + contentSHA256 := record.contentSHA256 + entry, reason, exact := record.fileProjection.Value(), record.fileReason, record.readbackExact + record.mu.RUnlock() + if !committed { + return + } + response["commit_seq"] = sequence + if contentSHA256 != "" { + response["content_sha256"] = contentSHA256 + } + // The original write receipt is historical; a newer registry entry must + // never be revived or advertised as this receipt's live file projection. + if liveProjectionForAcknowledgement(entry) { + response["file_view"] = fileViewForEntry(entry) + response["readback_exact"] = exact + } else { + if reason == "" { + reason = "projection_not_live" + } + response["file_view"] = mutationFileView{State: "unavailable", Reason: reason, Historical: exact, DeltaState: "unavailable", DeltaReason: "projection_not_live", SyntaxHealth: fileViewSyntaxHealth{State: "unknown"}} + response["readback_exact"] = false + } + attachMutationFileTier(response, response["readback_exact"] == true) + response["graph_status_hint"] = mutationFileGraphHint(response, response["readback_exact"] == true) + if reason != "" { + response["file_reason"] = reason + } +} + +// Receipt formatting cannot consume invalidation or perform a hash read. +// Suspect bytes belong to the bounded read-through/lookup revalidation path. +// Check the immutable pointer on both sides of metadata I/O so an old receipt +// cannot claim a concurrently replaced entry, even with identical bytes. +func liveProjectionForAcknowledgement(entry *projectionEntry) bool { + if entry == nil || entry.suspect.Load() || pendingFileProjections.snap.Load().entries[entry.key] != entry { + return false + } + stamp, err := statDiskStamp(entry.absPath) + if err != nil || !entry.disk.matches(stamp) { + return false + } + if entry.tombstone { + if stamp.info != nil { + return false + } + } else if stamp.info == nil || !stamp.info.Mode().IsRegular() { + return false + } + return !entry.suspect.Load() && pendingFileProjections.snap.Load().entries[entry.key] == entry +} + +// Bind accepted file facts to exactly this physical execution. Tombstones +// carry no syntax verdict because no candidate source bytes were parsed. +func publishPhysicalProjection(record *mutationCommitRecord, entry *projectionEntry, stamp diskStamp) string { + record.mu.RLock() + sequence := record.commitSeq + record.mu.RUnlock() + entry = entry.committedCopy(sequence, stamp) + if err := pendingFileProjections.publish(entry); err != nil { + if refusal, ok := err.(projectionRefusal); ok { + return string(refusal) + } + return "projection_refused" + } + record.mu.Lock() + record.fileProjection = weak.Make(entry) + record.mu.Unlock() + if !entry.tombstone { + if !entry.parseChecked { + return "syntax_unknown" + } + if entry.parseErrors > 0 { + return "parse_errors" + } + } + return "" +} diff --git a/internal/mcp/source_projection_file_view.go b/internal/mcp/source_projection_file_view.go new file mode 100644 index 000000000..0285efd14 --- /dev/null +++ b/internal/mcp/source_projection_file_view.go @@ -0,0 +1,265 @@ +package mcp + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unsafe" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" +) + +// This bounded immutable evidence lives only inside the existing projection +// registry. Physical receipts and replay caches must not retain its strings. +type fileViewEvidence struct { + Delta fileSymbolDelta + Syntax fileViewSyntaxHealth +} + +type fileViewSyntaxHealth struct { + State string `json:"state"` + Checked bool `json:"checked"` + Errors *int `json:"errors"` + Language string `json:"language,omitempty"` +} + +type mutationFileView struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Historical bool `json:"historical,omitempty"` + DeltaState string `json:"delta_state"` + DeltaReason string `json:"delta_reason,omitempty"` + Counts *fileSymbolDeltaCounts `json:"counts"` + Symbols []fileSymbolDeltaEntry `json:"symbols"` + Truncated bool `json:"truncated"` + SyntaxHealth fileViewSyntaxHealth `json:"syntax_health"` +} + +func projectionSyntaxHealth(entry *projectionEntry) fileViewSyntaxHealth { + health := fileViewSyntaxHealth{State: "unknown"} + if entry == nil { + return health + } + health.Language = entry.language + if entry.tombstone { + health.State = "not_applicable_removed" + return health + } + if entry.parseChecked && entry.parseErrors >= 0 && entry.hash == hex.EncodeToString(entry.rawHash[:]) { + errors := entry.parseErrors + health.State = "checked" + health.Checked = true + health.Errors = &errors + } + return health +} + +func (s *Server) prepareFileViewEvidence(ctx context.Context, prepared *preparedFileProjection, before *plannedSource, after []byte) { + if prepared == nil || prepared.entry == nil { + return + } + entry := prepared.entry + evidence := &fileViewEvidence{Syntax: projectionSyntaxHealth(entry)} + if !entry.tombstone && entry.hash != hex.EncodeToString(entry.rawHash[:]) { + evidence.Delta = fileSymbolDelta{State: "unavailable", Reason: "transformed_source"} + } else { + budget, cancel := context.WithTimeout(ctx, 30*time.Millisecond) + old, known, reason := s.fileViewBaseline(budget, prepared, before) + if before == nil { + before = &plannedSource{} + } + evidence.Delta = buildFileSymbolDelta(indexer.DecodeUTF16Source(before.before), indexer.DecodeUTF16Source(after), old, fileViewSymbols(entry.layer.FileNodes(entry.graphPath)), known) + if !known && reason != "" { + evidence.Delta.Reason = reason + } + if budget.Err() != nil { + evidence.Delta = fileSymbolDelta{State: "unavailable", Reason: "baseline_deadline"} + } + cancel() + } + // Tight copies prevent the display slice or a substring from retaining + // undisplayed changes or an entire older source outside the byte allowance. + evidence.Delta.Symbols = slices.Clone(evidence.Delta.Symbols) + for i := range evidence.Delta.Symbols { + row := &evidence.Delta.Symbols[i] + row.ID = strings.Clone(row.ID) + row.Kind = graph.NodeKind(strings.Clone(string(row.Kind))) + row.Signature = strings.Clone(row.Signature) + } + entry.fileView = evidence + entry.size += fileViewEvidenceCharge(evidence) +} + +func fileViewSymbols(nodes []*graph.Node) []*graph.Node { + symbols := make([]*graph.Node, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + symbols = append(symbols, nil) + continue + } + switch node.Kind { + case graph.KindFile, graph.KindPackage, graph.KindImport: + continue + } + symbols = append(symbols, node) + } + return symbols +} + +// Authenticate a complete prior symbol set against the very same selected +// reader and the caller's held-path byte snapshot. No canonical fallback can +// fill a missing selected capability, inventory row, or newer pointer. +func (s *Server) fileViewBaseline(ctx context.Context, prepared *preparedFileProjection, before *plannedSource) ([]*graph.Node, bool, string) { + entry := prepared.entry + if before == nil || pathkey.MutationKey(before.path) != prepared.key || !mutationPathAdmitted(ctx, prepared.key) { + return nil, false, "baseline_unknown" + } + if !before.existed { + stamp, err := statDiskStamp(entry.absPath) + if err == nil && stamp.info == nil && pendingFileProjections.snap.Load().entries[prepared.key] == prepared.expected { + return nil, true, "" + } + return nil, false, "absence_not_current" + } + rawHash := blake3.Sum256(before.before) + prior := prepared.expected + if prior != nil && !prior.tombstone && prior.graphPath == entry.graphPath && prior.repoPrefix == entry.repoPrefix && prior.language == entry.language && prior.rawHash == rawHash && !prior.suspect.Load() && pendingFileProjections.snap.Load().entries[prepared.key] == prior { + nodes := fileViewSymbols(prior.layer.FileNodes(prior.graphPath)) + if !prior.suspect.Load() && pendingFileProjections.snap.Load().entries[prepared.key] == prior { + return nodes, true, "" + } + return nil, false, "baseline_replaced" + } + reader := s.readerFor(ctx) + inventory, ok := reader.(graph.ConstantValueProjectionReader) + if !ok { + return nil, false, "baseline_metadata_unsupported" + } + key := graph.ConstantFileKey{RepoPrefix: entry.repoPrefix, FilePath: entry.graphPath} + read := func() (graph.FileMetaRow, bool) { + rows, err := inventory.ReadConstantValueProjectionContext(ctx, nil, []graph.ConstantFileKey{key}) + if err != nil { + return graph.FileMetaRow{}, false + } + row, found := rows.Files[key] + return row, found + } + first, found := read() + if !found || first.ContentHash == "" || first.ContentHash != hex.EncodeToString(rawHash[:]) { + return nil, false, "baseline_bytes_not_accepted" + } + rows, err := graph.ContractFileNodesContext(ctx, reader, entry.repoPrefix, []string{entry.graphPath}) + if err != nil { + return nil, false, "baseline_nodes_unavailable" + } + last, found := read() + if !found || first != last || ctx.Err() != nil || pendingFileProjections.snap.Load().entries[prepared.key] != prepared.expected { + return nil, false, "baseline_changed" + } + nodes, found := rows[entry.graphPath] + if !found || len(nodes) != first.NodeCount { + return nil, false, "baseline_nodes_incomplete" + } + return fileViewSymbols(nodes), true, "" +} + +func fileViewEvidenceCharge(e *fileViewEvidence) int64 { + if e == nil { + return 0 + } + // Round each separately owned allocation conservatively, including headers. + allocation := func(n int64) int64 { + if n <= 0 { + return 0 + } + return ((n+15)/16)*16 + 64 + } + size := allocation(int64(unsafe.Sizeof(*e))) + allocation(int64(cap(e.Delta.Symbols))*int64(unsafe.Sizeof(fileSymbolDeltaEntry{}))) + if e.Delta.Counts != nil { + size += allocation(int64(unsafe.Sizeof(*e.Delta.Counts))) + } + if e.Syntax.Errors != nil { + size += allocation(int64(unsafe.Sizeof(*e.Syntax.Errors))) + } + for _, row := range e.Delta.Symbols { + size += allocation(int64(len(row.ID))) + allocation(int64(len(row.Kind))) + allocation(int64(len(row.Signature))) + } + for _, value := range []string{e.Delta.State, e.Delta.Reason, e.Syntax.State, e.Syntax.Language} { + size += allocation(int64(len(value))) + } + return size +} + +func fileViewForEntry(entry *projectionEntry) mutationFileView { + view := mutationFileView{State: "projected", DeltaState: "unavailable", DeltaReason: "baseline_unknown", SyntaxHealth: projectionSyntaxHealth(entry)} + if entry != nil && entry.fileView != nil { + evidence := entry.fileView + view.DeltaState, view.DeltaReason = evidence.Delta.State, evidence.Delta.Reason + view.Counts, view.Symbols, view.Truncated = evidence.Delta.Counts, evidence.Delta.Symbols, evidence.Delta.Truncated + view.SyntaxHealth = evidence.Syntax + } + return view +} + +func mutationFileGraphHint(payload map[string]any, exact bool) string { + status, ok := payload["graph_status"].(string) + if !ok || status == "" { + status = "unobserved" + } + if exact { + return fmt.Sprintf("file current; recorded graph status: %s", status) + } + return fmt.Sprintf("file readback unavailable; recorded graph status: %s", status) +} + +func baselineForPreparedWrite(absPath string, before []byte) *plannedSource { + if before != nil { + return &plannedSource{path: absPath, before: before, existed: true} + } + _, err := os.Lstat(absPath) + if os.IsNotExist(err) { + return &plannedSource{path: absPath, existed: false} + } + return nil +} + +// Capture only a bounded, regular source under the existing physical path +// admission. Failure makes delta evidence unavailable; it never grants writes. +func captureFileViewBaseline(ctx context.Context, absPath string) *plannedSource { + if !mutationPathAdmitted(ctx, pathkey.MutationKey(absPath)) { + return nil + } + before, err := statDiskStamp(absPath) + if err != nil { + return nil + } + if before.info == nil { + return &plannedSource{path: absPath, existed: false} + } + if !before.info.Mode().IsRegular() || before.info.Size() > 1<<20 { + return nil + } + files, err := source.NewFilesystemSource(filepath.Dir(absPath)) + if err != nil { + return nil + } + defer files.Close() + raw, _, err := files.ReadRegularFile(ctx, filepath.Base(absPath), 1<<20) + if err != nil { + return nil + } + after, err := statDiskStamp(absPath) + if err != nil || !before.matches(after) || ctx.Err() != nil { + return nil + } + return &plannedSource{path: absPath, before: raw, existed: true} +} diff --git a/internal/mcp/source_projection_file_view_test.go b/internal/mcp/source_projection_file_view_test.go new file mode 100644 index 000000000..50e26c6a2 --- /dev/null +++ b/internal/mcp/source_projection_file_view_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "go.uber.org/zap" +) + +func TestMutationFileViewContainsCompleteDeltaAndCheckedSyntax(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(1) }\nfunc FileViewAdded() {}", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + view, ok := payload["file_view"].(map[string]any) + require.True(t, ok, "ACK file_view must carry the structured delta, not an availability-only scalar") + require.Equal(t, "projected", view["state"]) + require.Equal(t, "available", view["delta_state"]) + counts, ok := view["counts"].(map[string]any) + require.True(t, ok) + require.EqualValues(t, 4, counts["before"], "fixture includes Config, Port, main and helper") + require.EqualValues(t, 5, counts["after"]) + require.EqualValues(t, 1, counts["added"]) + require.EqualValues(t, 1, counts["changed"]) + require.EqualValues(t, 0, counts["removed"]) + require.EqualValues(t, 2, counts["total_changes"]) + require.Len(t, view["symbols"], 2) + syntax, ok := view["syntax_health"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, syntax["checked"]) + require.EqualValues(t, 0, syntax["errors"]) + require.Equal(t, "go", syntax["language"]) + require.Contains(t, payload["graph_status_hint"], "file current") + require.Contains(t, payload["graph_status_hint"], "pending") + require.Equal(t, "fresh", payload["tier_state"].(map[string]any)["file"]) +} + +func TestMutationReplayRefreshesActualTicketTiersWithoutAnotherParse(t *testing.T) { + s, root := ackModeServer(t, "local") + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) + require.NoError(t, err) + s.SetWatcher(watcher) + require.NoError(t, watcher.Start([]string{root})) + s.graph.ResolveMutex().Lock() + unlock := sync.OnceFunc(s.graph.ResolveMutex().Unlock) + stop := sync.OnceFunc(func() { require.NoError(t, watcher.Stop()) }) + t.Cleanup(func() { unlock(); stop() }) + args := map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(2) }", "mutation_id": "tier-replay-real-watcher", "format": "json"} + first := callTool(t, s, "edit_file", args) + require.False(t, first.IsError, "%v", first.Content) + payload := extractTextResult(t, first) + require.Equal(t, true, payload["reindex_pending"]) + tiers, ok := payload["tier_state"].(map[string]any) + require.True(t, ok) + require.Equal(t, "pending", tiers["relations"]) + receipt, ok := payload["reindex_receipt"].(string) + require.True(t, ok) + unlock() + waitCheckoutReceipt(t, s, receipt) + stop() // join native echoes before the replay parse-count observation + before := parser.ParseCount() + replay := callTool(t, s, "edit_file", args) + require.False(t, replay.IsError, "%v", replay.Content) + current := extractTextResult(t, replay) + require.Equal(t, "fresh", current["graph_status"]) + require.NotContains(t, current, "reindex_pending", "a pending ACK cache cannot override the actual terminal receipt") + liveTiers, ok := current["tier_state"].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh", liveTiers["relations"]) + require.Equal(t, before, parser.ParseCount(), "live receipt replay cannot parse or rewrite source") + require.Equal(t, "local", current["ack_mode"]) + require.NotEmpty(t, current["content_sha256"]) +} + +func requireMutationFileViewState(t *testing.T, payload map[string]any, expected string) { + t.Helper() + switch view := payload["file_view"].(type) { + case mutationFileView: + require.Equal(t, expected, view.State) + require.NotEmpty(t, view.DeltaState) + require.NotEmpty(t, view.SyntaxHealth.State) + case map[string]any: + require.Equal(t, expected, view["state"]) + require.NotEmpty(t, view["delta_state"]) + require.Contains(t, view, "syntax_health") + default: + t.Fatalf("file_view must be a structured object, got %T", view) + } +} + +func TestMutationFileViewCacheRetainsOnlyBoundedRegistryEvidence(t *testing.T) { + s, root := ackModeServer(t, "local") + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { println(3) }", "mutation_id": "bounded-delta-replay", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + payload := extractTextResult(t, result) + record, found := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, found) + cached, found := record.replayResponse() + require.True(t, found) + require.NotContains(t, cached, "file_view", "only the capped projection registry may retain symbol delta strings") + require.Equal(t, "fresh", payload["tier_state"].(map[string]any)["file"]) + pendingFileProjections.markSuspect(filepath.Join(root, "main.go")) + unavailable := make(map[string]any) + attachMutationCommit(unavailable, record) + requireMutationFileViewState(t, unavailable, "unavailable") + require.Equal(t, "pending", unavailable["tier_state"].(map[string]string)["file"]) + require.Nil(t, unavailable["file_view"].(mutationFileView).Counts) +} diff --git a/internal/mcp/source_projection_frontier.go b/internal/mcp/source_projection_frontier.go new file mode 100644 index 000000000..8f4280a54 --- /dev/null +++ b/internal/mcp/source_projection_frontier.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "context" + "time" + "weak" +) + +// This caller-owned slice dies after the held source frontier finishes. The +// bounded physical ledger never strongly retains a graph or a prepared delta. +type committedFileProjection struct { + record *mutationCommitRecord + prepared *preparedFileProjection +} + +func (s *Server) publishCommittedProjectionCohort(ctx context.Context, writes []committedFileProjection) { + // A cancellation after a successful physical write cannot discard that + // write's file facts. This bounded verification does no source mutation. + checked, cancel := context.WithTimeout(context.WithoutCancel(ctx), 150*time.Millisecond) + defer cancel() + members := make([]projectionCohortMember, 0, len(writes)) + owners := make([]*mutationCommitRecord, 0, len(writes)) + for _, write := range writes { + record, prepared := write.record, write.prepared + reason := "projection_not_prepared" + if prepared != nil && prepared.refusal != "" { + reason = prepared.refusal + } + if record == nil { + continue + } + record.mu.RLock() + sequence, committed, sha := record.commitSeq, record.disk == mutationDiskCommitted, record.contentSHA256 + record.mu.RUnlock() + if !committed { + continue + } + if prepared != nil && prepared.entry != nil { + entry, err := s.verifyPreparedProjection(checked, prepared, sequence, sha) + if err == nil { + members = append(members, projectionCohortMember{Entry: entry, Expected: prepared.expected}) + owners = append(owners, record) + continue + } + reason = "readback_unavailable" + } + record.mu.Lock() + record.fileReason = reason + record.readbackExact = false + record.mu.Unlock() + } + err := pendingFileProjections.publishCohort(members) + for i, record := range owners { + entry := members[i].Entry + record.mu.Lock() + if err != nil { + record.fileReason = "projection_refused" + if refusal, ok := err.(projectionRefusal); ok { + record.fileReason = string(refusal) + } + record.readbackExact = false + } else { + record.fileProjection = weak.Make(entry) + record.readbackExact = true + record.fileReason = "" + if !entry.tombstone && !entry.parseChecked { + record.fileReason = "syntax_unknown" + } + if !entry.tombstone && entry.parseChecked && entry.parseErrors > 0 { + record.fileReason = "parse_errors" + } + } + record.mu.Unlock() + } +} diff --git a/internal/mcp/source_projection_gate.go b/internal/mcp/source_projection_gate.go new file mode 100644 index 000000000..9cf4245f8 --- /dev/null +++ b/internal/mcp/source_projection_gate.go @@ -0,0 +1,117 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/zzet/gortex/internal/pathkey" + + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer" +) + +// prepareAcknowledgedProjection builds only caller-owned file facts while the +// original source path is still held. A refused projection preserves the +// existing syntax gate; it cannot relax write admission. +func (s *Server) prepareAcknowledgedProjection(ctx context.Context, path, absPath string, before, after []byte) (*preparedFileProjection, parseGateResult) { + s.noteAcknowledgementParser(ctx, "source_projection_parse_gate") + lap := s.beginAcknowledgementLap(ctx, editAckParseGate) + defer lap.finish() + prepared, syntax := s.buildFileProjection(ctx, absPath, after) + gate := projectedParseGate(path, before, after, syntax) + lap.applicable = gate.Checked + lap.finish() + s.prepareFileViewEvidence(ctx, prepared, baselineForPreparedWrite(absPath, before), after) + return prepared, gate +} + +// Coordinate preservation does not prove equal syntax: a prepass can blank +// bytes without moving them. Reuse only a checked full parse of identical +// candidate bytes and the very same gate language, never a default zero. +func projectedParseGate(path string, before, after []byte, projection *indexer.Projection) parseGateResult { + if projection != nil && projection.ParseChecked && projection.ParseErrors >= 0 && projection.Language == parseGateLanguage(path) { + sum := blake3.Sum256(after) + if projection.ContentHash == hex.EncodeToString(sum[:]) { + return checkParseGateWithVerdict(path, before, after, true, projection.ParseErrors) + } + } + return checkParseGate(path, before, after) +} + +// This short-lived preparation never becomes a second authority or a ledger +// graph cache. The registry alone may retain its admitted entry after commit. +type preparedFileProjection struct { + key string + rawHash [32]byte + contentSHA256 string + entry *projectionEntry + expected *projectionEntry + refusal string +} + +func (s *Server) buildFileProjection(ctx context.Context, absPath string, raw []byte) (*preparedFileProjection, *indexer.Projection) { + key := pathkey.MutationKey(absPath) + sum := sha256.Sum256(raw) + prepared := &preparedFileProjection{key: key, rawHash: blake3.Sum256(raw), contentSHA256: hex.EncodeToString(sum[:]), expected: pendingFileProjections.snap.Load().entries[key]} + if !pendingFileProjections.enabled { + prepared.refusal = "disabled" + return prepared, nil + } + if ctx.Err() != nil { + prepared.refusal = "cancelled" + return prepared, nil + } + ownerPath := s.overlayOwnerAbsPath(ctx, absPath) + idx := s.pickIndexerForPath(ownerPath) + if idx == nil { + prepared.refusal = "parser_unavailable" + return prepared, nil + } + projection, err := idx.ProjectSource(ctx, ownerPath, raw) + if err != nil { + switch { + case errors.Is(err, indexer.ErrProjectionTooLarge): + prepared.refusal = "source_too_large" + case errors.Is(err, indexer.ErrProjectionLanguage): + prepared.refusal = "language_not_projected" + case errors.Is(err, indexer.ErrProjectionTransform): + prepared.refusal = "transform_not_projected" + case errors.Is(err, indexer.ErrProjectionNoSlot): + prepared.refusal = "parse_slot_deadline" + case errors.Is(err, indexer.ErrProjectionDeadline): + prepared.refusal = "build_deadline" + default: + prepared.refusal = "extraction_refused" + } + return prepared, nil + } + prepared.entry = newProjectionEntry(absPath, idx.RepoPrefix(), projection, raw, 0, diskStamp{}) + prepared.entry.contentSHA256 = prepared.contentSHA256 + prepared.entry.size += 128 // separately owned raw SHA256 and allocation/header allowance + return prepared, projection +} + +// Removal owns the directory entry, including a leaf symlink; it never parses +// or follows the removed leaf to construct the file tombstone. +func (s *Server) buildRemovalProjection(ctx context.Context, relPath, absPath string) *preparedFileProjection { + key := pathkey.MutationKey(absPath) + prepared := &preparedFileProjection{key: key, expected: pendingFileProjections.snap.Load().entries[key]} + if !pendingFileProjections.enabled { + prepared.refusal = "disabled" + return prepared + } + if ctx.Err() != nil { + prepared.refusal = "cancelled" + return prepared + } + idx := s.pickIndexerForPath(s.overlayOwnerAbsPath(ctx, absPath)) + if idx == nil { + prepared.refusal = "parser_unavailable" + return prepared + } + graphPath := s.resolveOverlayGraphPathForRequest(ctx, relPath, absPath) + prepared.entry = newProjectionTombstone(absPath, graphPath, idx.RepoPrefix(), 0) + return prepared +} diff --git a/internal/mcp/source_projection_gate_test.go b/internal/mcp/source_projection_gate_test.go new file mode 100644 index 000000000..bb7206d87 --- /dev/null +++ b/internal/mcp/source_projection_gate_test.go @@ -0,0 +1,88 @@ +package mcp + +import ( + "context" + "encoding/hex" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestPreparedProjectionSyntaxGateParsesCleanCandidateOnce(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(context.Background(), path) + require.NoError(t, err) + defer release() + before := []byte("package p\nfunc Good() {}\n") + for _, test := range []struct { + name, source string + parses uint64 + blocked bool + }{ + {"clean", "package p\nfunc Better() {}\n", 1, false}, + {"regression", "package p\nfunc Better() { _ = }\n", 2, true}, + {"repair", "package p\nfunc Better() {}\n", 1, false}, + } { + t.Run(test.name, func(t *testing.T) { + old := before + if test.name == "repair" { + old = []byte("package p\nfunc Good() { _ = }\n") + } + count := parser.ParseCount() + entry, verdict := s.prepareAcknowledgedProjection(ctx, "main.go", path, old, []byte(test.source)) + parses := parser.ParseCount() - count + require.NotNil(t, entry) + require.NotNil(t, entry.entry, entry.refusal) + require.True(t, verdict.Checked) + require.Equal(t, test.blocked, verdict.Blocked) + require.Equal(t, test.parses, parses, "projection and gate must share the exact candidate parse; only broken candidates parse old bytes") + }) + } +} + +func TestProjectedParseGateUnknownOrDifferentBytesUseOriginalGate(t *testing.T) { + before := []byte("package p\nfunc Good() {}\n") + after := []byte("package p\nfunc Bad() { _ = }\n") + sum := blake3.Sum256(after) + hash := hex.EncodeToString(sum[:]) + for _, test := range []struct { + name string + projection *indexer.Projection + }{ + {"absent", nil}, + {"old or plugin response", &indexer.Projection{Language: "go", ContentHash: hash}}, + {"invalid negative", &indexer.Projection{Language: "go", ContentHash: hash, ParseChecked: true, ParseErrors: -1}}, + {"different parsed bytes", &indexer.Projection{Language: "go", ContentHash: "different parsed source", ParseChecked: true}}, + {"different grammar", &indexer.Projection{Language: "python", ContentHash: hash, ParseChecked: true}}, + } { + t.Run(test.name, func(t *testing.T) { + expected := checkParseGate("main.go", before, after) + count := parser.ParseCount() + actual := projectedParseGate("main.go", before, after, test.projection) + require.Equal(t, expected, actual) + require.True(t, actual.Blocked) + require.EqualValues(t, 2, parser.ParseCount()-count, "unknown proof must parse candidate and old bytes") + }) + } +} + +func TestProjectedParseGateKeepsBinaryAndOldErrorSemantics(t *testing.T) { + for _, test := range []struct{ before, after string }{ + {"package p\n", "package p\nfunc F() { _ = }\n"}, + {"package p\nfunc F() { _ = }\n", "package p\nfunc F() { _ = }\n"}, + {"package p\nfunc F() { _ = }\n", "package p\n"}, + {"package p\nfunc F() { _ = }\n", "package p\x00"}, + {"package p\x00", "package p\n"}, + } { + after := []byte(test.after) + count, checked := parseErrorCount("go", after) + sum := blake3.Sum256(after) + projection := &indexer.Projection{Language: "go", ContentHash: hex.EncodeToString(sum[:]), ParseChecked: checked, ParseErrors: count} + require.Equal(t, checkParseGate("main.go", []byte(test.before), after), projectedParseGate("main.go", []byte(test.before), after, projection)) + } +} diff --git a/internal/mcp/source_projection_hash_test.go b/internal/mcp/source_projection_hash_test.go new file mode 100644 index 000000000..295e6cb35 --- /dev/null +++ b/internal/mcp/source_projection_hash_test.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestMutationContentSHA256UsesActualCommittedBytesAndSurvivesReplay(t *testing.T) { + s, root := setupTestServer(t) + published := make(chan indexer.MutationResult, 1) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + args := map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() { println(\"Δ\") }", "mutation_id": "raw-sha-proof", "format": "json"} + before := parser.ParseCount() + result := callTool(t, s, "edit_file", args) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 1, parser.ParseCount()-before, "raw byte identity must reuse the one candidate parse") + raw, err := os.ReadFile(path) + require.NoError(t, err) + sum := sha256.Sum256(raw) + expected := hex.EncodeToString(sum[:]) + payload := extractTextResult(t, result) + require.Equal(t, expected, payload["content_sha256"]) + require.NotEqual(t, gitBlobSHA(raw), expected) + record, found := s.mutationCommits.byReceipt(payload["mutation_receipt"].(string)) + require.True(t, found) + require.NotNil(t, record) + require.Equal(t, expected, s.mutationStatusPayload(record)["content_sha256"]) + replayParses := parser.ParseCount() + replay := callTool(t, s, "edit_file", args) + require.False(t, replay.IsError, "%v", replay.Content) + require.Equal(t, expected, extractTextResult(t, replay)["content_sha256"]) + require.Equal(t, replayParses, parser.ParseCount(), "replay must not parse again to reconstruct a hash") +} + +func TestMutationContentSHA256DoesNotInventHashForModeledLegacyRecord(t *testing.T) { + s := newMutationServer() + path := filepath.Join(t.TempDir(), "legacy.go") + record := s.beginMutationCommit(t.Context(), "write_file", "", "", "legacy.go", path) + record.markCommitted("legacy-git-blob", 99) + payload := map[string]any{} + attachMutationCommit(payload, record) + require.NotContains(t, payload, "content_sha256", "a blob label is not a verified raw-byte SHA256") + require.NotContains(t, s.mutationStatusPayload(record), "content_sha256") +} diff --git a/internal/mcp/source_projection_removal_test.go b/internal/mcp/source_projection_removal_test.go new file mode 100644 index 000000000..3a127aabd --- /dev/null +++ b/internal/mcp/source_projection_removal_test.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestGuardedRemovalPublishesExactTombstone(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + require.NotNil(t, s.graph.GetNode("main.go::helper")) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + require.Equal(t, mutationDiskCommitted, record.diskStatus()) + _, err = os.Lstat(path) + require.ErrorIs(t, err, os.ErrNotExist) + entry, err := pendingFileProjections.lookup(path) + require.NoError(t, err) + require.NotNil(t, entry, "a guarded source removal must admit a file tombstone at the physical commit") + require.True(t, entry.tombstone) + require.Positive(t, entry.commitSeq) + require.Nil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::helper")) + require.NotNil(t, s.graph.GetNode("main.go::helper"), "the tombstone does not mutate shared graph state") + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "projected") + require.Equal(t, true, response["readback_exact"]) +} + +func TestGuardedRemovalTombstoneOwnsLeafEntryNotLinkTarget(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + target := filepath.Join(root, "main.go") + before, err := os.ReadFile(target) + require.NoError(t, err) + link := filepath.Join(root, "link.go") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + ctx, release, err := acquireMutationPath(t.Context(), link) + require.NoError(t, err) + defer release() + _, err = s.commitFileMutation(ctx, "overlay_merge", "", "", "link.go", link, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + current, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, before, current) + entry := liveEntry(pendingFileProjections, link) + require.NotNil(t, entry) + require.True(t, entry.tombstone) + require.Equal(t, "link.go", entry.graphPath) + require.Nil(t, liveEntry(pendingFileProjections, target)) + require.NotNil(t, graph.NewOverlaidView(s.graph, entry.layer).GetNode("main.go::helper")) +} + +func TestGuardedRemovalRefusesLiveTombstoneAfterRecreation(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("package main\n"), 0o600)) + response := make(map[string]any) + attachMutationCommit(response, record) + requireMutationFileViewState(t, response, "unavailable") + require.Equal(t, false, response["readback_exact"]) + entry, err := pendingFileProjections.lookup(path) + require.ErrorIs(t, err, errProjectionExternalWrite) + require.Nil(t, entry) +} + +func TestGuardedRemovalHonorsProjectionDisable(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + pendingFileProjections.enabled = false + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + record, err := s.commitFileMutation(ctx, "overlay_merge", "", "", "main.go", path, nil, 0, sourceMutationOperation{kind: sourceRemove}) + require.NoError(t, err) + response := make(map[string]any) + attachMutationCommit(response, record) + require.Equal(t, "disabled", response["file_reason"]) + requireMutationFileViewState(t, response, "unavailable") + require.Empty(t, pendingFileProjections.snap.Load().entries) +} diff --git a/internal/mcp/source_projection_replay_test.go b/internal/mcp/source_projection_replay_test.go new file mode 100644 index 000000000..cd614e41c --- /dev/null +++ b/internal/mcp/source_projection_replay_test.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestReplayAfterNewerCommitKeepsLiveProjection(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + done := make(chan indexer.MutationResult, 2) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + s.mutationReindexWait = time.Nanosecond + path := filepath.Join(root, "main.go") + firstArgs := map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc FirstProjection() {}", "mutation_id": "projection-old", "format": "json"} + first := callTool(t, s, "edit_file", firstArgs) + require.False(t, first.IsError) + originalAck := extractTextResult(t, first) + requireMutationFileViewState(t, originalAck, "projected") + firstEntry := liveEntry(pendingFileProjections, path) + require.NotNil(t, firstEntry) + initialParses := parser.ParseCount() + same := callTool(t, s, "edit_file", firstArgs) + require.False(t, same.IsError) + requireMutationFileViewState(t, extractTextResult(t, same), "projected") + require.Equal(t, true, extractTextResult(t, same)["readback_exact"]) + require.Equal(t, initialParses, parser.ParseCount()) + require.Same(t, firstEntry, liveEntry(pendingFileProjections, path)) + second := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func FirstProjection() {}", "new_string": "func SecondProjection() {}", "mutation_id": "projection-new", "format": "json"}) + require.False(t, second.IsError) + live := liveEntry(pendingFileProjections, path) + require.NotSame(t, firstEntry, live) + beforeReplay, err := os.ReadFile(path) + require.NoError(t, err) + parses := parser.ParseCount() + replay := callTool(t, s, "edit_file", firstArgs) + require.False(t, replay.IsError) + payload := extractTextResult(t, replay) + require.Equal(t, true, payload["replayed"]) + require.Equal(t, originalAck["mutation_receipt"], payload["mutation_receipt"], "the physical receipt remains historical") + require.Equal(t, originalAck["commit_seq"], payload["commit_seq"]) + requireMutationFileViewState(t, payload, "unavailable") + require.Equal(t, false, payload["readback_exact"]) + require.Equal(t, "projection_not_live", payload["file_reason"]) + require.Equal(t, parses, parser.ParseCount(), "replay must neither parse nor resurrect the historical entry") + require.Same(t, live, liveEntry(pendingFileProjections, path)) + afterReplay, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, beforeReplay, afterReplay) + reader := graph.NewOverlaidView(s.graph, live.layer) + require.NotNil(t, reader.GetNode("main.go::SecondProjection")) + require.Nil(t, reader.GetNode("main.go::FirstProjection")) +} diff --git a/internal/mcp/source_projection_retirement.go b/internal/mcp/source_projection_retirement.go new file mode 100644 index 000000000..02d299f36 --- /dev/null +++ b/internal/mcp/source_projection_retirement.go @@ -0,0 +1,33 @@ +package mcp + +import "github.com/zzet/gortex/internal/pathkey" + +// This work belongs only to the daemon's canonical backend, whose lifetime is +// independent of a request/view pin. Checkout tickets never use this fallback: +// their retirement needs checked metadata from the selected generation stack. +type sourceProjectionRetirement struct { + registry *pendingProjections + backend projectionStore + key string +} + +func (s *Server) canonicalProjectionRetirement(absPath string) *sourceProjectionRetirement { + registry := pendingFileProjections + if !registry.enabled || len(registry.snap.Load().entries) == 0 { + return nil + } + backend, ok := s.graph.(projectionStore) + if !ok { + return nil + } + return &sourceProjectionRetirement{registry: registry, backend: backend, key: pathkey.MutationKey(absPath)} +} + +func (work *sourceProjectionRetirement) run() { + if work == nil { + return + } + // Retain the physical key captured at admission, rather than resolving an + // alias again after publication. The same hash/node/hash and COW checks run. + work.registry.retireSelected(work.backend, map[string]struct{}{work.key: {}}) +} diff --git a/internal/mcp/source_projection_retirement_callback_test.go b/internal/mcp/source_projection_retirement_callback_test.go new file mode 100644 index 000000000..60b3f64d1 --- /dev/null +++ b/internal/mcp/source_projection_retirement_callback_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +type projectionRetirementSignalStore struct { + *graph.Graph + once sync.Once + metadataRead chan struct{} +} + +func (s *projectionRetirementSignalStore) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + rows, err := s.Graph.FileMetasByPaths(prefix, paths) + s.once.Do(func() { close(s.metadataRead) }) + return rows, err +} + +func TestCanonicalPublicationRetirementRequiresActualBackendContent(t *testing.T) { + for _, published := range []bool{true, false} { + name := "receipt_only" + if published { + name = "actual_publication" + } + t.Run(name, func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + registry := pendingFileProjections + t.Cleanup(func() { pendingFileProjections = previous }) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, registry.publish(entry)) + backend := &projectionRetirementSignalStore{Graph: fixture.g, metadataRead: make(chan struct{})} + s := newMutationServer() + s.graph = backend + s.indexer = fixture.idx + done := make(chan indexer.MutationResult, 1) + t.Cleanup(func() { close(done) }) + s.SetWatcher(mutationTestWatcher{done: done}) + ctx, release, err := acquireMutationPath(t.Context(), entry.absPath) + require.NoError(t, err) + admission := s.admitSourceMutations(ctx, entry.absPath) + require.NotNil(t, admission.receipt) + release() + if published { + require.NoError(t, fixture.idx.IndexFile(filepath.Join(fixture.dir, "a.go"))) + require.NotNil(t, fixture.g.GetNode("a.go::Published"), "prove actual graph publication independently of the completion ticket") + } else { + require.NotNil(t, fixture.g.GetNode("a.go::Old"), "the success notification alone cannot become content evidence") + } + done <- indexer.MutationResult{RequestedGeneration: admission.receipt.generation, AppliedGeneration: admission.receipt.generation, Reindexed: true} + select { + case <-admission.receipt.done: + case <-time.After(3 * time.Second): + t.Fatal("owned ticket did not complete") + } + select { + case <-backend.metadataRead: + case <-time.After(3 * time.Second): + t.Fatal("canonical publication never verified the pending projection against its backend") + } + if published { + require.Eventually(t, func() bool { return liveEntry(registry, entry.absPath) == nil }, 3*time.Second, time.Millisecond) + } else { + require.Same(t, entry, liveEntry(registry, entry.absPath)) + require.Zero(t, registry.retired.Load()) + } + }) + } +} + +type projectionRetirementCoreOnlyStore struct{ graph.Store } + +func TestCanonicalRetirementKeepsEntryWithoutMetadataCapability(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + fixture := newProjectionFixture(t) + entry := fixture.edit("a.go", "package p\n\nfunc Published() {}\n") + require.NoError(t, pendingFileProjections.publish(entry)) + s := newMutationServer() + s.graph = &projectionRetirementCoreOnlyStore{Store: fixture.g} + require.NotImplements(t, (*graph.FileMetaPathReader)(nil), s.graph) + work := s.canonicalProjectionRetirement(entry.absPath) + require.Nil(t, work, "a core wrapper must never be unwrapped to another metadata authority") + work.run() + require.Same(t, entry, liveEntry(pendingFileProjections, entry.absPath)) +} + +func TestInlinePublicationRetiresVerifiedProjection(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + registry := pendingFileProjections + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + result := callTool(t, s, "edit_file", map[string]any{"path": path, "old_string": "func helper() {}", "new_string": "func helper() {}\nfunc InlinePublished() {}", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + response := extractTextResult(t, result) + require.Equal(t, true, response["reindexed"], "prove real embedded publication before checking cache retirement") + require.NotNil(t, s.graph.GetNode("main.go::InlinePublished")) + require.Positive(t, registry.published.Load(), "the source commit must have admitted its projection first") + require.Nil(t, liveEntry(registry, path), "synchronous canonical publication must retire content-verified file data too") + require.Positive(t, registry.retired.Load()) +} diff --git a/internal/mcp/source_projection_span_test.go b/internal/mcp/source_projection_span_test.go new file mode 100644 index 000000000..c8d2abfb3 --- /dev/null +++ b/internal/mcp/source_projection_span_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" +) + +func TestSymbolSpanUsesMatchingPendingProjectionWithoutAnotherParse(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + raw := append([]byte("\n\n"), before...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + projection, err := s.indexer.ProjectSource(t.Context(), path, raw) + require.NoError(t, err) + entry := newProjectionEntry(path, s.indexer.RepoPrefix(), projection, raw, 1, stamp) + require.NoError(t, pendingFileProjections.publish(entry)) + node := s.graph.GetNode("main.go::helper") + require.NotNil(t, node) + current := entry.layer.NodeByID(node.ID) + require.NotNil(t, current) + require.NotEqual(t, node.StartLine, current.StartLine) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + parses := parser.ParseCount() + start, end, err := s.symbolLinesUnderLock(ctx, node, path, raw) + require.NoError(t, err) + require.Equal(t, current.StartLine, start) + require.Equal(t, current.EndLine, end) + require.EqualValues(t, 0, parser.ParseCount()-parses, "the same BLAKE3-matched file projection must serve the held-path declaration resolver") +} + +func TestSymbolSpanRejectsMismatchedProjectionBytesAndRemovedDeclaration(t *testing.T) { + for _, removed := range []bool{false, true} { + t.Run(map[bool]string{false: "shifted again", true: "removed"}[removed], func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + projectedBytes := append([]byte("\n\n"), before...) + require.NoError(t, os.WriteFile(path, projectedBytes, 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + projection, err := s.indexer.ProjectSource(t.Context(), path, projectedBytes) + require.NoError(t, err) + entry := newProjectionEntry(path, s.indexer.RepoPrefix(), projection, projectedBytes, 1, stamp) + require.NoError(t, pendingFileProjections.publish(entry)) + node := s.graph.GetNode("main.go::helper") + require.NotNil(t, node) + current := append([]byte("\n\n\n\n"), before...) + if removed { + current = []byte(strings.ReplaceAll(string(current), "func helper() {}", "func Gone() {}")) + } + require.NoError(t, os.WriteFile(path, current, 0o644)) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + parses := parser.ParseCount() + start, _, err := s.symbolLinesUnderLock(ctx, node, path, current) + if removed { + require.ErrorIs(t, err, errSymbolRemoved) + } else { + require.NoError(t, err) + require.Equal(t, node.StartLine+4, start) + } + require.EqualValues(t, 1, parser.ParseCount()-parses, "different raw identity must take the conservative full extraction") + }) + } +} diff --git a/internal/mcp/source_projection_tiers_test.go b/internal/mcp/source_projection_tiers_test.go new file mode 100644 index 000000000..e7d89b254 --- /dev/null +++ b/internal/mcp/source_projection_tiers_test.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" +) + +func TestMutationAckIncludesExistingTierProducerEvidence(t *testing.T) { + s, root := setupTestServer(t) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) + require.NoError(t, err) + s.SetWatcher(watcher) + require.NoError(t, watcher.Start([]string{root})) + t.Cleanup(func() { require.NoError(t, watcher.Stop()) }) + s.mutationReindexWait = time.Nanosecond + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 3 }", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + response := extractTextResult(t, result) + require.NotZero(t, response["reindex_generation"], "prove real watcher admission before checking evidence") + states, ok := response["tier_state"].(map[string]any) + require.True(t, ok, "the acknowledgement must consume the existing ticket-owned tier producer") + require.Len(t, states, 4) + require.Equal(t, "fresh", states["file"], "only the exact live physical projection supplies this additional tier") + ack, ok := response["ack_at"].(string) + require.True(t, ok) + at, err := time.Parse(time.RFC3339Nano, ack) + require.NoError(t, err) + require.False(t, at.IsZero()) + for tier, field := range map[string]string{"file_graph": "file_graph_at", "relations": "relations_at", "derived": "derived_at"} { + require.Contains(t, []string{"pending", "fresh", "partial", "failed"}, states[tier]) + if states[tier] == "fresh" { + value, ok := response[field].(string) + require.True(t, ok, "fresh %s must have actual publication time", tier) + stamp, err := time.Parse(time.RFC3339Nano, value) + require.NoError(t, err) + require.False(t, stamp.IsZero()) + } + } +} diff --git a/internal/mcp/source_projection_verify.go b/internal/mcp/source_projection_verify.go new file mode 100644 index 000000000..4b792b228 --- /dev/null +++ b/internal/mcp/source_projection_verify.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "context" + "fmt" + "github.com/zeebo/blake3" + "github.com/zzet/gortex/internal/indexer/source" + "github.com/zzet/gortex/internal/pathkey" + "path/filepath" +) + +// verifyPreparedProjection authenticates an after-image while its original +// physical path remains held. It performs bounded I/O before cache admission; +// the cohort registry itself never performs I/O or supplies disk authority. +// expectedSHA256 is the durable journal's after-image SHA, when applicable. +func (s *Server) verifyPreparedProjection(ctx context.Context, prepared *preparedFileProjection, sequence uint64, expectedSHA256 string) (*projectionEntry, error) { + if prepared == nil || prepared.entry == nil { + return nil, fmt.Errorf("projection preparation unavailable") + } + entry := prepared.entry + if sequence == 0 || !mutationPathAdmitted(ctx, prepared.key) || pathkey.MutationKey(entry.absPath) != prepared.key { + return nil, fmt.Errorf("projection held-path identity unavailable") + } + if err := ctx.Err(); err != nil { + return nil, err + } + before, err := statDiskStamp(entry.absPath) + if err != nil { + return nil, err + } + if entry.tombstone { + if before.info != nil || expectedSHA256 != "" { + return nil, fmt.Errorf("projection removal after-image changed") + } + return entry.committedCopy(sequence, before), nil + } + if expectedSHA256 != "" && prepared.contentSHA256 != expectedSHA256 { + return nil, fmt.Errorf("projection journal after-image mismatch") + } + if before.info == nil || !before.info.Mode().IsRegular() || before.info.Size() > 1<<20 { + return nil, fmt.Errorf("projection after-image is not a bounded regular file") + } + files, err := source.NewFilesystemSource(filepath.Dir(entry.absPath)) + if err != nil { + return nil, err + } + raw, _, readErr := files.ReadRegularFile(ctx, filepath.Base(entry.absPath), 1<<20) + files.Close() + after, err := statDiskStamp(entry.absPath) + if readErr != nil { + return nil, readErr + } + if err != nil { + return nil, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if !before.matches(after) || blake3.Sum256(raw) != prepared.rawHash || pathkey.MutationKey(entry.absPath) != prepared.key || !mutationPathAdmitted(ctx, prepared.key) { + return nil, fmt.Errorf("projection after-image changed") + } + return entry.committedCopy(sequence, after), nil +} diff --git a/internal/mcp/source_projection_verify_test.go b/internal/mcp/source_projection_verify_test.go new file mode 100644 index 000000000..dc6b92372 --- /dev/null +++ b/internal/mcp/source_projection_verify_test.go @@ -0,0 +1,45 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPreparedProjectionVerificationRequiresHeldExactJournalAfterImage(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + ctx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + defer release() + prepared, _ := s.buildFileProjection(ctx, path, raw) + require.NotNil(t, prepared.entry) + before := pendingFileProjections.snap.Load() + _, err = s.verifyPreparedProjection(t.Context(), prepared, 1, prepared.contentSHA256) + require.Error(t, err, "an otherwise exact after-image is not held-path proof") + _, err = s.verifyPreparedProjection(ctx, prepared, 1, "different-journal-sha") + require.Error(t, err) + entry, err := s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.NoError(t, err) + require.EqualValues(t, 1, entry.commitSeq) + require.Equal(t, prepared.contentSHA256, entry.contentSHA256) + require.NotSame(t, prepared.entry, entry) + require.Same(t, before, pendingFileProjections.snap.Load(), "verification alone must never publish a member") + canceled, cancel := context.WithCancel(ctx) + cancel() + _, err = s.verifyPreparedProjection(canceled, prepared, 1, prepared.contentSHA256) + require.ErrorIs(t, err, context.Canceled) + changed := append([]byte(nil), raw...) + changed[len(changed)-1] = ' ' + require.NoError(t, os.WriteFile(path, changed, 0o644)) + _, err = s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.Error(t, err, "journal SHA cannot substitute for exact current after-image verification") + release() + _, err = s.verifyPreparedProjection(ctx, prepared, 1, prepared.contentSHA256) + require.Error(t, err, "a released path proof cannot authorize publication") +} diff --git a/internal/mcp/source_remaining_frontends_test.go b/internal/mcp/source_remaining_frontends_test.go new file mode 100644 index 000000000..a5f2413a4 --- /dev/null +++ b/internal/mcp/source_remaining_frontends_test.go @@ -0,0 +1,360 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +func TestScaffoldAdmitsOneFinalFileBeforeUnlockedPublicationWait(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + publications := make(chan indexer.MutationResult, 1) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPath(ctx, path) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::helper", "new_name": "ClonedHelper", "dry_run": false, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + parses := parser.ParseCount() + go func() { defer close(finished); result, resultErr = s.handleScaffold(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned scaffold did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{path}, admitted) + case <-finished: + t.Fatalf("scaffold returned without admitting its physical source: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("scaffold never admitted its physical source") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(raw), "func ClonedHelper() {}") + require.EqualValues(t, 1, watcher.points.Load(), "multiple scaffold edits to one file have one final publication") + require.Len(t, note.verdict().Snapshots, 1, "same-file insertions are one physical candidate/write") + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("scaffold did not return its path lock before waiting") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published scaffold did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 1, parser.ParseCount()-parses, "one full clean candidate parse per physical file; queued publisher is a no-parse fixture") +} + +func TestScaffoldRefusesExampleRemovedWhileWaitingForSourceLock(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + writerCtx, release, err := acquireMutationPath(t.Context(), path) + require.NoError(t, err) + var once sync.Once + unlock := func() { once.Do(release) } + defer unlock() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::helper", "new_name": "ClonedHelper", "dry_run": false, "format": "json"} + finished := make(chan struct{}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleScaffold(ctx, request) }() + defer func() { + cancel() + unlock() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned queued scaffold did not join") + } + }() + waitForMutationRefs(t, path, 2) + before, err := os.ReadFile(path) + require.NoError(t, err) + current := []byte(strings.ReplaceAll(string(before), "func helper() {}", "func RemovedAlternative() {}")) + _, err = s.commitFileMutation(writerCtx, "write_file", "", "", "main.go", path, current, 0o644) + require.NoError(t, err) + unlock() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("queued scaffold did not return") + } + require.NoError(t, resultErr) + require.True(t, result.IsError, "a stale outside-lock template cannot resurrect a removed example") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, current, after) +} + +func TestScaffoldUsesTheRequestedReaderInsteadOfCanonicalGraph(t *testing.T) { + s, root := setupTestServer(t) + path := filepath.Join(root, "main.go") + raw, err := os.ReadFile(path) + require.NoError(t, err) + raw = append(raw, []byte("\nfunc SelectedOnly() int { return 7 }\n")...) + require.NoError(t, os.WriteFile(path, raw, 0o644)) + projection, err := s.indexer.ProjectSource(t.Context(), path, raw) + require.NoError(t, err) + selected := graph.New() + selected.AddBatch(projection.Nodes, projection.Edges) + require.NotNil(t, selected.GetNode("main.go::SelectedOnly")) + require.Nil(t, s.graph.GetNode("main.go::SelectedOnly")) + ctx := withRequestView(t.Context(), &requestView{reader: selected, viewRoot: root}) + s.SetWatcher(mutationTestWatcher{}) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "main.go::SelectedOnly", "new_name": "ClonedSelected", "dry_run": false, "format": "json"} + result, err := s.handleScaffold(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(after), "func ClonedSelected() int { return 7 }") + require.Nil(t, s.graph.GetNode("main.go::ClonedSelected"), "the selected reader cannot become a canonical graph write") +} + +func TestOverlayDiskMergeAdmitsWholeFrontierBeforeUnlockedWait(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "queued-writer", "activate": true}) + cohort := map[string]string{first: "package main\nfunc Target() {}\nfunc AddedTarget() {}\n", second: "package main\nfunc Caller() { Target() }\nfunc AddedCaller() {}\n"} + for path, body := range cohort { + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: body}, nil)) + } + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{first, second} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "queued-writer", "to_disk": true, "format": "json"} + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleOverlayMerge(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned overlay merge did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("overlay merge returned without watcher frontier admission: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("overlay merge never admitted its source frontier") + } + for path, body := range cohort { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, body, string(raw)) + } + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("overlay merge kept source locks during passive wait") + } + managerRead := make(chan error, 1) + go func() { _, err := s.OverlayManager().Branches(session); managerRead <- err }() + select { + case err := <-managerRead: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("overlay manager mutex was retained across publication wait") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published overlay merge did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) +} + +func TestOverlayDiskMergeRefusesChangedBranchAfterPathWait(t *testing.T) { + for _, change := range []string{"content", "membership"} { + t.Run(change, func(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "changing", "activate": true}) + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: first, Content: "package main\nfunc Replacement() {}\n"}, nil)) + before, err := os.ReadFile(first) + require.NoError(t, err) + _, release, err := acquireMutationPath(t.Context(), first) + require.NoError(t, err) + unlock := sync.OnceFunc(release) + defer unlock() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "changing", "to_disk": true} + type response struct { + result *mcplib.CallToolResult + err error + } + done := make(chan response, 1) + go func() { result, err := s.handleOverlayMerge(ctx, request); done <- response{result, err} }() + defer func() { + cancel() + unlock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("owned branch merge did not join") + } + }() + waitForMutationRefs(t, first, 2) + path := first + if change == "membership" { + path = second + } + changed := make(chan error, 1) + go func() { + changed <- s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: "package main\nfunc NewerOverlay() {}\n"}, nil) + }() + select { + case err := <-changed: + require.NoError(t, err) + case <-time.After(time.Second): + unlock() + require.NoError(t, <-changed) + t.Fatal("branch mutex held while acquiring disk paths") + } + unlock() + select { + case result := <-done: + done <- result // leave the owned join receipt for cleanup + require.NoError(t, result.err) + require.True(t, result.result.IsError, "%v", result.result.Content) + case <-time.After(3 * time.Second): + t.Fatal("changed branch merge did not refuse") + } + after, err := os.ReadFile(first) + require.NoError(t, err) + require.Equal(t, before, after, "cohort mismatch must refuse before the first physical write") + files, err := s.OverlayManager().Files(session) + require.NoError(t, err) + require.Contains(t, files, path, "refusal must retain the newer source branch") + }) + } +} + +func TestOverlayDiskMergeLaterCancellationStillAdmitsCommittedSubset(t *testing.T) { + s, session, _, first, second, ctx := branchBootstrap(t) + _, _ = invokeTool(t, s, ctx, "overlay_fork", map[string]any{"name": "partial", "activate": true}) + paths := []string{first, second} + slices.Sort(paths) + originalSecond, err := os.ReadFile(paths[1]) + require.NoError(t, err) + body := "package main\nfunc PartialReplacement() {}\n" + for _, path := range paths { + require.NoError(t, s.OverlayManager().Push(session, daemon.OverlayFile{Path: path, Content: body}, nil)) + } + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + ctx, note := withMutationCommitNote(ctx) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if record.absPath == paths[1] { + cancel() + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"from": "partial", "to_disk": true} + result, err := s.handleOverlayMerge(ctx, request) + require.NoError(t, err) + require.True(t, result.IsError, "%v", result.Content) + actualFirst, err := os.ReadFile(paths[0]) + require.NoError(t, err) + require.Equal(t, body, string(actualFirst)) + actualSecond, err := os.ReadFile(paths[1]) + require.NoError(t, err) + require.Equal(t, originalSecond, actualSecond) + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{paths[0]}, admitted) + default: + t.Fatal("later cancellation lost the first physical file's publication admission") + } + var committed int + for _, snapshot := range note.verdict().Snapshots { + if snapshot.DiskStatus == mutationDiskCommitted { + committed++ + require.True(t, snapshot.GraphRecorded, "the committed subset retains its publication outcome") + } + } + require.Equal(t, 1, committed) +} diff --git a/internal/mcp/source_rename_frontier_test.go b/internal/mcp/source_rename_frontier_test.go new file mode 100644 index 000000000..d173d5b2b --- /dev/null +++ b/internal/mcp/source_rename_frontier_test.go @@ -0,0 +1,144 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" +) + +type renameFrontierWatcher struct { + mutationTestWatcher + points, sets atomic.Int64 + announced chan []string +} + +func (w *renameFrontierWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + w.points.Add(1) + select { + case w.announced <- []string{path}: + default: + } + return w.mutationTestWatcher.EnqueueToolMutation(ctx, path) +} +func (w *renameFrontierWatcher) EnqueueFileMutations(ctx context.Context, paths []string) (map[string]*indexer.MutationTicket, error) { + w.sets.Add(1) + tickets := make(map[string]*indexer.MutationTicket, len(paths)) + for _, path := range paths { + ticket, err := w.mutationTestWatcher.EnqueueToolMutation(ctx, path) + if err != nil { + return nil, err + } + tickets[path] = ticket + } + select { + case w.announced <- append([]string(nil), paths...): + default: + } + return tickets, nil +} + +func TestRenameAdmitsWholeFrontierAndReleasesPathsBeforeGraphWait(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + publications := make(chan indexer.MutationResult, 2) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{filepath.Join(root, "caller.go"), filepath.Join(root, "target.go")} + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + ctx, cancel := context.WithCancel(t.Context()) + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + finished := make(chan struct{}) + var result *mcplib.CallToolResult + var resultErr error + complete := sync.OnceFunc(func() { + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + go func() { defer close(finished); result, resultErr = s.handleRenameSymbol(ctx, req) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned rename did not join during cleanup") + } + }() + select { + case admitted := <-watcher.announced: + assert.ElementsMatch(t, paths, admitted, "admit the full physical frontier once") + case <-time.After(3 * time.Second): + t.Fatal("rename never admitted publication") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(raw), "Renamed", "every file must be committed before admission/wait") + } + assert.EqualValues(t, 1, watcher.sets.Load()) + assert.Zero(t, watcher.points.Load(), "whole-set support must not pay point admission per file") + select { + case err := <-waiting: + require.NoError(t, err, "a real competing path admission must succeed while publication is pending") + case <-time.After(time.Second): + t.Fatal("rename waits before finishing and releasing its source frontier") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published rename did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, "applied", decodeRenameResp(t, result)["status"]) +} + +func TestRenameCandidateProjectionAndGateParseEachFileOnce(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, _ := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + publications := make(chan indexer.MutationResult, 2) + for range 2 { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + before := parser.ParseCount() + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.EqualValues(t, 2, parser.ParseCount()-before, "both clean candidates must share their full parse between syntax gate and pending projection") +} diff --git a/internal/mcp/source_rename_parallel_projection_test.go b/internal/mcp/source_rename_parallel_projection_test.go new file mode 100644 index 000000000..fb1f11f45 --- /dev/null +++ b/internal/mcp/source_rename_parallel_projection_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +type renameParallelExtractor struct { + base parser.Extractor + enabled atomic.Bool + active, max atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (e *renameParallelExtractor) Language() string { return "go" } +func (e *renameParallelExtractor) Extensions() []string { return []string{".go"} } +func (e *renameParallelExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { + if e.enabled.Load() { + active := e.active.Add(1) + defer e.active.Add(-1) + for prior := e.max.Load(); active > prior; prior = e.max.Load() { + if e.max.CompareAndSwap(prior, active) { + break + } + } + e.entered <- struct{}{} + <-e.release + } + return e.base.Extract(path, raw) +} + +func TestRenameBuildsBoundedParallelProjectionsInsideOneAcknowledgementLap(t *testing.T) { + t.Setenv("GORTEX_EDIT_PARSE_GATE", "1") + extractor := &renameParallelExtractor{base: languages.NewGoExtractor(), entered: make(chan struct{}, 3), release: make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s, root := setupTestServerWithRegistry(t, registry) + var edits []renameEdit + var paths []string + for _, name := range []string{"first.go", "second.go", "third.go"} { + path := filepath.Join(root, name) + require.NoError(t, os.WriteFile(path, []byte("package main\nfunc Old() {}\n"), 0o644)) + paths = append(paths, path) + edits = append(edits, renameEdit{File: name, Line: 2, OldText: "func Old() {}", NewText: "func New() {}"}) + } + _, err := s.indexer.IncrementalReindexPaths(root, paths) + require.NoError(t, err) + held, releasePaths, err := acquireMutationPaths(t.Context(), paths) + require.NoError(t, err) + defer releasePaths() + ctx, ack, _ := s.withEditAcknowledgement(held) + extractor.enabled.Store(true) + unblock := sync.OnceFunc(func() { close(extractor.release) }) + defer unblock() + done := make(chan struct{}) + var writes []*renameFileWrite + var planErr error + go func() { defer close(done); writes, planErr = s.planRenameWrites(ctx, edits, false) }() + t.Cleanup(func() { + unblock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("owned projection workers remain during cleanup") + } + }) + select { + case <-extractor.entered: + case <-time.After(3 * time.Second): + t.Fatal("first real projection extractor did not enter") + } + parallel := false + select { + case <-extractor.entered: + parallel = true + case <-time.After(time.Second): + } + unblock() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("owned projection workers did not join after release") + } + require.NoError(t, planErr) + require.Len(t, writes, 3) + for _, write := range writes { + require.NotNil(t, write.projection.entry, "bounded parallel preparation must finish each checked candidate, not escape through a timed-out first parse") + } + assert.True(t, parallel, "a second real projection must begin before the first can finish") + assert.EqualValues(t, 2, extractor.max.Load(), "parallelism is bounded rather than one goroutine per file") + ack.finishActual() + ack.finishReply(mcplib.NewToolResultText("planned without writes"), nil) + timing := ack.snapshot().ParseGate + assert.EqualValues(t, 1, timing.Started, "parallel preparation has one actual wall lap, never overlapping additive per-file laps") + assert.EqualValues(t, 1, timing.Completed) + assert.Equal(t, "measured", timing.State) + for _, path := range paths { + raw, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Equal(t, "package main\nfunc Old() {}\n", string(raw)) + } +} diff --git a/internal/mcp/source_rename_projection.go b/internal/mcp/source_rename_projection.go new file mode 100644 index 000000000..30adbfac0 --- /dev/null +++ b/internal/mcp/source_rename_projection.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "fmt" + "sync" +) + +// Parse at most two candidates concurrently, then authenticate metadata after +// their single wall-time lap. The call stack owns preparations only until the +// physical frontier; graphs never enter the mutation ledger or journal state. +func (s *Server) finishRenamePlans(ctx context.Context, writes []*renameFileWrite, allowParseErrors bool, priorError error) ([]*renameFileWrite, error) { + if len(writes) == 0 { + return writes, priorError + } + gated := parseGateEnabled() + var lap editAcknowledgementLap + if gated || s.indexer != nil || s.multiIndexer != nil { + scope := "source_projection" + if gated { + scope = "source_projection_parse_gate" + } + s.noteAcknowledgementParser(ctx, scope) + lap = s.beginAcknowledgementLap(ctx, editAckParseGate) + } + var budgetMu sync.Mutex + var bytes int64 + files := 0 + jobs := make(chan *renameFileWrite) + var workers sync.WaitGroup + for range min(2, len(writes)) { + workers.Go(func() { + for write := range jobs { + candidate, syntax := s.buildFileProjection(ctx, write.AbsPath, write.New) + if gated { + write.Gate = projectedParseGate(write.RelPath, write.Old, write.New, syntax) + } + budgetMu.Lock() + if candidate.entry != nil { + if files >= projectionMaxFiles || bytes+candidate.entry.size > projectionMaxBytes { + candidate.entry = nil + candidate.refusal = "cohort_preparation_limit" + } else { + files++ + bytes += candidate.entry.size + } + } + write.projection = candidate + budgetMu.Unlock() + } + }) + } + for _, write := range writes { + jobs <- write + } + close(jobs) + workers.Wait() + checked := false + for _, write := range writes { + checked = checked || write.Gate.Checked + } + lap.applicable = !gated || checked + lap.finish() + for _, write := range writes { + if write.Gate.Blocked && !allowParseErrors { + return nil, fmt.Errorf("%s", parseGateError(write.RelPath, write.Gate)) + } + write.GateInfo = parseGateInfo(write.Gate, allowParseErrors) + } + if priorError != nil { + return nil, priorError + } + for _, write := range writes { + candidate := write.projection + if candidate.entry == nil { + continue + } + previousSize := candidate.entry.size + s.prepareFileViewEvidence(ctx, candidate, baselineForPreparedWrite(write.AbsPath, write.Old), write.New) + if bytes+candidate.entry.size-previousSize > projectionMaxBytes { + bytes -= previousSize + candidate.entry = nil + candidate.refusal = "cohort_preparation_limit" + } else { + bytes += candidate.entry.size - previousSize + } + } + return writes, nil +} diff --git a/internal/mcp/source_rename_projection_cohort_test.go b/internal/mcp/source_rename_projection_cohort_test.go new file mode 100644 index 000000000..f382f8f79 --- /dev/null +++ b/internal/mcp/source_rename_projection_cohort_test.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" +) + +func TestRenamePublishesNoFileViewBeforeCompletePhysicalFrontier(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + before := pendingFileProjections.snap.Load() + calls := 0 + s.mutationPreCommitHook = func(*mutationCommitRecord) { + calls++ + if calls == 2 { + assert.Same(t, before, pendingFileProjections.snap.Load(), "a second source file is not yet committed: no member may be visible") + raw, err := os.ReadFile(filepath.Join(root, "caller.go")) + require.NoError(t, err) + assert.Contains(t, string(raw), "Renamed", "the first actual write must have completed before the visibility check") + } + } + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, 2, calls) + after := pendingFileProjections.snap.Load() + require.NotSame(t, before, after) + for _, name := range []string{"caller.go", "target.go"} { + entry := liveEntry(pendingFileProjections, filepath.Join(root, name)) + require.NotNil(t, entry) + require.Positive(t, entry.commitSeq) + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} + +func TestRenamePartialCancellationPublishesOnlyActuallyCommittedSubset(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + defer close(published) + s.SetWatcher(mutationTestWatcher{done: published}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writes := make([]*renameFileWrite, 0, 2) + paths := make([]string, 0, 2) + for _, name := range []string{"caller.go", "target.go"} { + path := filepath.Join(root, name) + raw, err := os.ReadFile(path) + require.NoError(t, err) + writes = append(writes, &renameFileWrite{RelPath: name, AbsPath: path, Old: raw, New: []byte(strings.ReplaceAll(string(raw), "Target", "Renamed"))}) + paths = append(paths, path) + } + held, release, err := acquireMutationPaths(ctx, paths) + require.NoError(t, err) + defer release() + seen := 0 + s.mutationPreCommitHook = func(*mutationCommitRecord) { + seen++ + if seen == 2 { + cancel() + } + } + results := s.commitRenameWrites(held, writes, release) + require.Len(t, results, 2) + require.Equal(t, "applied", results[0]["status"]) + require.Equal(t, "failed", results[1]["status"]) + require.Equal(t, mutationDiskNotApplied, results[1]["disk_status"]) + require.Equal(t, true, results[0]["readback_exact"], "cancellation after the first physical effect must retain that exact committed subset") + require.NotNil(t, liveEntry(pendingFileProjections, paths[0])) + require.Nil(t, liveEntry(pendingFileProjections, paths[1])) + first, err := os.ReadFile(paths[0]) + require.NoError(t, err) + require.Equal(t, writes[0].New, first) + second, err := os.ReadFile(paths[1]) + require.NoError(t, err) + require.Equal(t, writes[1].Old, second) +} + +func TestRenameProjectionCapacityRefusalLeavesWholeCacheUnchanged(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + published := make(chan indexer.MutationResult, 2) + for range 2 { + published <- indexer.MutationResult{Reindexed: true} + } + close(published) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: published}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + require.NoError(t, pendingFileProjections.publish(&projectionEntry{key: "other-physical-owner", graphPath: "other.go", size: projectionMaxBytes - 32})) + before := pendingFileProjections.snap.Load() + req := mcplib.CallToolRequest{} + req.Params.Name = "rename_symbol" + req.Params.Arguments = map[string]any{"id": "target.go::Target", "new_name": "Renamed", "format": "json"} + result, err := s.handleRenameSymbol(t.Context(), req) + require.NoError(t, err) + require.False(t, result.IsError, "cache refusal must preserve the actual disk verdict: %v", result.Content) + require.Same(t, before, pendingFileProjections.snap.Load(), "combined cap refusal must not admit even one rename member") + for _, name := range []string{"caller.go", "target.go"} { + raw, readErr := os.ReadFile(filepath.Join(root, name)) + require.NoError(t, readErr) + require.Contains(t, string(raw), "Renamed") + } + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) +} diff --git a/internal/mcp/source_safe_delete_frontier_test.go b/internal/mcp/source_safe_delete_frontier_test.go new file mode 100644 index 000000000..6f143758b --- /dev/null +++ b/internal/mcp/source_safe_delete_frontier_test.go @@ -0,0 +1,347 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +func TestPropagateUnknownReferenceNeverInventsForce(t *testing.T) { + for _, explicitForce := range []bool{false, true} { + t.Run(map[bool]string{false: "no explicit force", true: "explicit force retained"}[explicitForce], func(t *testing.T) { + s, root := newSafeDeleteTestServer(t) + path := filepath.Join(root, "foo.go") + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": path + "::Live", "dry_run": false, "propagate": true, "force": explicitForce, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(path) + require.NoError(t, err) + if explicitForce { + require.NotContains(t, string(raw), "func Live()") + } else { + require.Contains(t, string(raw), "func Live()", "an unlocated reference cannot disappear merely because the propagation plan omitted it") + } + }) + } +} + +func TestPropagateMustNotRemoveAnotherStatementOnTheSameLine(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "del.go": "package p\nfunc Target() {}\n", + "caller.go": "package p\nfunc Caller() {\n Target(); Keep()\n}\nfunc Keep() {}\n", + }) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "caller.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "Target(); Keep()", "a source line with another statement requires manual patching, not whole-line deletion") + raw, err = os.ReadFile(filepath.Join(root, "del.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Target()") +} + +func TestPropagateLaterFailureRetainsAppliedPatchAndPublication(t *testing.T) { + s, root := setupMoveInlineRepo(t, map[string]string{ + "del.go": "package p\nfunc Target() {}\n", + "a.go": "package p\nfunc First() {\n Target()\n}\n", + "z.go": "package p\nfunc Last() {\n Target()\n}\n", + }) + publications := make(chan indexer.MutationResult) + defer close(publications) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Nanosecond + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s.mutationPreCommitHook = func(record *mutationCommitRecord) { + if filepath.Base(record.absPath) == "z.go" { + cancel() + } + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + first := filepath.Join(root, "a.go") + raw, err := os.ReadFile(first) + require.NoError(t, err) + require.NotContains(t, string(raw), "Target()", "prove the first physical caller patch committed before the second refused") + require.Contains(t, toolResultText(result), "patches_applied", "the error path must retain actual completed source effects") + require.Contains(t, toolResultText(result), "a.go") + select { + case admitted := <-watcher.announced: + require.Equal(t, []string{first}, admitted) + default: + t.Fatal("a later failure abandoned publication of the first physical caller patch") + } + for _, name := range []string{"z.go", "del.go"} { + raw, err := os.ReadFile(filepath.Join(root, name)) + require.NoError(t, err) + require.Contains(t, string(raw), "Target()") + } +} + +func TestPropagateKnownAndUnknownReferenceKeepsUnknownAuthority(t *testing.T) { + s, g, root := setupDeleteServer(t) + g.AddEdge(&graph.Edge{From: "caller.go::Caller", To: "del.go::Target", Kind: graph.EdgeReferences, FilePath: "caller.go"}) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + result, err := s.handleSafeDeleteSymbol(t.Context(), request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + raw, err := os.ReadFile(filepath.Join(root, "del.go")) + require.NoError(t, err) + require.True(t, strings.Contains(string(raw), "func Target()"), "removing one exact call cannot certify an unknown reference as removed") +} + +func TestSafeDeleteRevalidatesAuthorityAfterWholeFrontierLock(t *testing.T) { + for _, mode := range []string{"published reference", "unpublished reference", "cascade member gains caller"} { + t.Run(mode, func(t *testing.T) { + rootBody := "package p\nfunc Root() {}\n" + if mode == "cascade member gains caller" { + rootBody = "package p\nfunc Root() { Child() }\n" + } + s, root := setupMoveInlineRepo(t, map[string]string{ + "root.go": rootBody, "child.go": "package p\nfunc Child() {}\n", "outside.go": "package p\nfunc Outside() {}\n", + }) + locked := filepath.Join(root, "root.go") + if mode == "cascade member gains caller" { + locked = filepath.Join(root, "child.go") + } + _, release, err := acquireMutationPath(t.Context(), locked) + require.NoError(t, err) + var once sync.Once + unlock := func() { once.Do(release) } + defer unlock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "root.go::Root", "dry_run": false, "format": "json"} + if mode == "cascade member gains caller" { + request.Params.Arguments.(map[string]any)["cascade"] = "apply" + } + var publication chan indexer.MutationResult + if mode == "unpublished reference" { + publication = make(chan indexer.MutationResult, 1) + s.SetWatcher(mutationTestWatcher{done: publication}) + } + type response struct { + result *mcplib.CallToolResult + err error + } + finished := make(chan response, 1) + go func() { result, err := s.handleSafeDeleteSymbol(ctx, request); finished <- response{result, err} }() + joined := false + defer func() { + cancel() + unlock() + if !joined { + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned delete did not join during cleanup") + } + } + }() + waitForMutationRefs(t, locked, 2) + callee := "Root" + if mode == "cascade member gains caller" { + callee = "Child" + } + outside := filepath.Join(root, "outside.go") + writeCtx, releaseWriter, err := acquireMutationPath(t.Context(), outside) + require.NoError(t, err) + defer releaseWriter() + _, err = s.commitFileMutation(writeCtx, "write_file", "", "", "outside.go", outside, []byte("package p\nfunc Outside() { "+callee+"() }\n"), 0o644) + require.NoError(t, err, "a real independent source commit lands while the delete is queued") + var receipt *mutationReceipt + if mode == "unpublished reference" { + admission := s.admitSourceMutations(writeCtx, outside) + receipt = admission.receipt + require.NotNil(t, receipt) + } else { + s.graph.AddEdge(&graph.Edge{From: "outside.go::Outside", To: strings.ToLower(callee) + ".go::" + callee, Kind: graph.EdgeCalls, FilePath: "outside.go", Line: 2}) + } + releaseWriter() + unlock() + var actual response + select { + case actual = <-finished: + joined = true + case <-time.After(3 * time.Second): + t.Fatal("delete did not return after the path frontier was released") + } + if receipt != nil { + publication <- indexer.MutationResult{RequestedGeneration: receipt.generation, AppliedGeneration: receipt.generation, Reindexed: true} + close(publication) + <-receipt.done + } + require.NoError(t, actual.err) + require.False(t, actual.result.IsError, "%v", actual.result.Content) + raw, err := os.ReadFile(filepath.Join(root, "root.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Root()", "new reference/closure authority must be checked after the final union admission, before any delete") + if mode == "cascade member gains caller" { + raw, err = os.ReadFile(filepath.Join(root, "child.go")) + require.NoError(t, err) + require.Contains(t, string(raw), "func Child()", "a newly referenced cascade member must not be deleted under an obsolete closure") + } + require.Contains(t, toolResultText(actual.result), "source_authority_changed") + }) + } +} + +func TestPropagateDeleteAdmitsOnlyTheFinalCompleteSourceFrontier(t *testing.T) { + for _, sameFile := range []bool{false, true} { + t.Run(map[bool]string{false: "two files", true: "same physical file"}[sameFile], func(t *testing.T) { + files := map[string]string{"del.go": "package p\nfunc Target() {}\n", "caller.go": "package p\nfunc Caller() {\n Target()\n}\n"} + if sameFile { + files = map[string]string{"del.go": "package p\nfunc Target() {}\nfunc Caller() {\n Target()\n}\n"} + } + s, root := setupMoveInlineRepo(t, files) + publications := make(chan indexer.MutationResult, len(files)) + watcher := &renameFrontierWatcher{mutationTestWatcher: mutationTestWatcher{done: publications}, announced: make(chan []string, 1)} + s.SetWatcher(watcher) + s.mutationReindexWait = time.Minute + paths := []string{filepath.Join(root, "del.go")} + if !sameFile { + paths = append(paths, filepath.Join(root, "caller.go")) + } + waiting := make(chan error, 1) + s.releaseDispatchSlot = func(context.Context, <-chan struct{}) bool { + check, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, release, err := acquireMutationPaths(check, paths) + if err == nil { + release() + } + waiting <- err + return true + } + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": "del.go::Target", "dry_run": false, "propagate": true, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + ctx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + complete := sync.OnceFunc(func() { + for range len(files) { + publications <- indexer.MutationResult{RequestedGeneration: 1, AppliedGeneration: 1, Reindexed: true} + } + close(publications) + }) + var result *mcplib.CallToolResult + var resultErr error + go func() { defer close(finished); result, resultErr = s.handleSafeDeleteSymbol(ctx, request) }() + defer func() { + cancel() + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Error("owned propagate/delete did not join") + } + }() + select { + case admitted := <-watcher.announced: + require.ElementsMatch(t, paths, admitted) + case <-finished: + t.Fatalf("propagate/delete returned before admitting its final frontier: %v %v", resultErr, result.Content) + case <-time.After(3 * time.Second): + t.Fatal("propagate/delete never admitted its final frontier") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(raw), "Target()", "both caller removal and definition delete precede the first admission") + } + select { + case err := <-waiting: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("source locks were not released before publication wait") + } + complete() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("published operation did not return") + } + require.NoError(t, resultErr) + require.False(t, result.IsError, "%v", result.Content) + snapshots := note.verdict().Snapshots + require.Len(t, snapshots, 2, "the caller patch and the definition deletion are real physical writes") + if sameFile { + require.EqualValues(t, 1, watcher.points.Load()) + require.Zero(t, watcher.sets.Load()) + recorded := 0 + for _, snapshot := range snapshots { + if snapshot.GraphRecorded { + recorded++ + } + } + require.Equal(t, 1, recorded, "only final bytes may receive publication success; the intermediate physical receipt stays historical") + } else { + require.EqualValues(t, 1, watcher.sets.Load()) + require.Zero(t, watcher.points.Load()) + } + }) + } +} + +func TestPropagateChangedSourcePreviewNeverAuthorizesApply(t *testing.T) { + s, root := newRelationFixture(t) + caller := writeUnindexed(t, root, "caller.go", "package p\n\nfunc Caller() {\n\tTarget()\n}\n\nfunc More() {}\n") + pendingFreshnessReceipt(s, "preview-caller", "", caller, 1) + target := filepath.Join(root, "target.go") + beforeCaller, err := os.ReadFile(caller) + require.NoError(t, err) + beforeTarget, err := os.ReadFile(target) + require.NoError(t, err) + id := targetID(t, s) + request := mcplib.CallToolRequest{} + request.Params.Arguments = map[string]any{"id": id, "propagate": true, "format": "json"} + ctx, note := withMutationCommitNote(t.Context()) + preview, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + require.False(t, preview.IsError, "%v", preview.Content) + payload := extractTextResult(t, preview) + require.Equal(t, "propagation_plan", payload["status"]) + require.EqualValues(t, 0, payload["manual"]) + require.Equal(t, true, payload["source_revalidation_required"]) + require.Contains(t, payload["hint"], "not write authorization") + require.Empty(t, note.verdict().Snapshots) + // A private preview row is also not an authorization token for the writer. + plan := s.buildPropagationPreview(ctx, s.readerFor(ctx), s.readerFor(ctx).GetNode(id)) + applied, failed, err := s.applyRemoveLinePatches(ctx, plan) + require.NoError(t, err) + require.Empty(t, applied) + require.Len(t, failed, 1) + require.Equal(t, "source_unverified", failed[0].Reason) + require.Empty(t, note.verdict().Snapshots) + request.Params.Arguments.(map[string]any)["dry_run"] = false + result, err := s.handleSafeDeleteSymbol(ctx, request) + require.NoError(t, err) + require.False(t, result.IsError, "%v", result.Content) + require.Equal(t, "propagation_blocked_manual", extractTextResult(t, result)["status"]) + require.Empty(t, note.verdict().Snapshots) + afterCaller, err := os.ReadFile(caller) + require.NoError(t, err) + afterTarget, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, beforeCaller, afterCaller) + require.Equal(t, beforeTarget, afterTarget) +} diff --git a/internal/mcp/source_write_census_test.go b/internal/mcp/source_write_census_test.go index 510a7a677..eada8b4b5 100644 --- a/internal/mcp/source_write_census_test.go +++ b/internal/mcp/source_write_census_test.go @@ -20,7 +20,7 @@ const sourceWriteChokePoint = "Server.commitFileMutation" // sourceWriteCensusEnforced turns the census from a report into a gate once // every source writer routes through the choke point. -const sourceWriteCensusEnforced = false +const sourceWriteCensusEnforced = true // nonSourceWriters may reference a file writer without the choke point: they // write tool outputs, transaction journals and spill files, or they are a @@ -29,7 +29,8 @@ var nonSourceWriters = map[string]string{ "durableAtomicWriteFile": "the durable write primitive", "Server.persistBatchManifest": "batch transaction manifest", "Server.prepareBatchJournal": "batch transaction backups", - "Server.rollbackBatchReceipt": "restores a failed batch from its backups", + "Server.batchDurability": "selects the durable source primitive", + "Server.cleanupBatchBackups": "removes transaction backups only", "symbolPageStorage.close": "symbol page spill directory", "symbolPageStorage.spill": "symbol page spill directory", "Server.handleExportGraph": "export output path", @@ -51,10 +52,14 @@ func sourceWriteSink(n ast.Node) string { return "os." + sel.Sel.Name case x.Name == "agents" && sel.Sel.Name == "AtomicWriteFile": return "agents.AtomicWriteFile" + case x.Name == "platform" && sel.Sel.Name == "RemoveFile": + return "platform.RemoveFile" + case sel.Sel.Name == "writeFile" || sel.Sel.Name == "removeFile": + return "durable." + sel.Sel.Name } case *ast.CallExpr: - if inner, ok := x.Fun.(*ast.SelectorExpr); ok && inner.Sel.Name == "batchDurability" && sel.Sel.Name == "writeFile" { - return "batchDurability().writeFile" + if inner, ok := x.Fun.(*ast.SelectorExpr); ok && inner.Sel.Name == "batchDurability" && (sel.Sel.Name == "writeFile" || sel.Sel.Name == "removeFile") { + return "batchDurability()." + sel.Sel.Name } } return "" @@ -133,11 +138,16 @@ func (s *Server) handleFixture() { write := s.batchDurability().writeFile _ = write os.Remove("x") + ops := s.batchDurability() + _ = ops.removeFile + platform.RemoveFile("x") } `, parser.SkipObjectResolution) require.NoError(t, err) require.Equal(t, []string{ "fixture.go: Server.handleFixture -> batchDurability().writeFile", + "fixture.go: Server.handleFixture -> durable.removeFile", "fixture.go: Server.handleFixture -> os.Remove", + "fixture.go: Server.handleFixture -> platform.RemoveFile", }, sourceWriteBypasses(map[string]*ast.File{"fixture.go": fixture})) } diff --git a/internal/mcp/symbol_write_runtime_test.go b/internal/mcp/symbol_write_runtime_test.go new file mode 100644 index 000000000..d4ff995bc --- /dev/null +++ b/internal/mcp/symbol_write_runtime_test.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestSymbolWritersUseCurrentDeclarationAfterLineShift(t *testing.T) { + for _, tool := range []string{"edit_symbol", "safe_delete_symbol"} { + t.Run(tool, func(t *testing.T) { + original := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + srv, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + srv.SetWatcher(mutationTestWatcher{}) + shifted := strings.Replace(original, "func first", "// a\n// b\n// c\nfunc first", 1) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte(shifted), 0o644)) + req := editFileRequest(map[string]any{ + "id": "sample.go::second", "old_source": "\treturn nil", "new_source": "\treturn errSecond", "force": true, "dry_run": false, "format": "json", + }) + req.Params.Name = tool + var result *mcp.CallToolResult + var callErr error + if tool == "edit_symbol" { + ctx, ack, _ := srv.withEditAcknowledgement(t.Context()) + defer ack.finishActual() + result, callErr = srv.handleEditSymbol(ctx, req) + ack.finishReply(result, callErr) + ack.finishActual() + observed := ack.snapshot() + require.EqualValues(t, 1, observed.ParseGate.Started, "actual source relocation extraction must be observed") + require.EqualValues(t, 1, observed.ParseGate.Completed) + require.True(t, observed.LapsDisjoint) + } else { + result, callErr = srv.handleSafeDeleteSymbol(t.Context(), req) + } + require.NoError(t, callErr) + require.False(t, result.IsError, "%v", result.Content) + current, err := os.ReadFile(path) + require.NoError(t, err) + first, _, found := strings.Cut(string(current), "func second") + require.Contains(t, first, "func first") + require.Contains(t, first, "\treturn nil", "the repeated fragment in the other declaration must survive") + if tool == "edit_symbol" { + require.True(t, found) + require.NotContains(t, first, "errSecond") + require.Contains(t, string(current), "\treturn errSecond") + } else { + require.False(t, found, "delete must remove the current second declaration") + } + }) + } +} + +func TestSafeDeleteRemovedDeclarationRefusesBeforeSourceWrite(t *testing.T) { + original := strings.Replace(repeatedFragmentSource, "// second documents itself.\n", "", 1) + srv, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + srv.SetWatcher(mutationTestWatcher{}) + removed := strings.Replace(original, "func second() error {\n\treturn nil\n}", "// declaration removed\n\n\n", 1) + require.NotEqual(t, original, removed) + path := filepath.Join(root, "sample.go") + require.NoError(t, os.WriteFile(path, []byte(removed), 0o644)) + result, callErr := srv.handleSafeDeleteSymbol(t.Context(), editFileRequest(map[string]any{"id": "sample.go::second", "force": true, "dry_run": false, "format": "json"})) + require.NoError(t, callErr) + require.True(t, result.IsError, "a removed current declaration cannot donate its old span: %v", result.Content) + current, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, removed, string(current)) + _, committed := srv.mutationCommits.recentForPath(path) + require.False(t, committed, "refusal must precede physical source authority") +} diff --git a/internal/mcp/tool_deadline.go b/internal/mcp/tool_deadline.go index bb8171f87..4ef231d5c 100644 --- a/internal/mcp/tool_deadline.go +++ b/internal/mcp/tool_deadline.go @@ -537,6 +537,17 @@ func (s *Server) boundToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHan } func (s *Server) runAcknowledgedEdit(ctx context.Context, req mcp.CallToolRequest, h mcpserver.ToolHandlerFunc) (result *mcp.CallToolResult, err error) { + if s.editAckConfigErr != nil { + return mcp.NewToolResultError(s.editAckConfigErr.Error()), nil + } + if policy, captured := ctx.Value(sourceAckPolicyKey{}).(sourceAckPolicy); !captured || policy.owner != s { + policy = s.sourceAckPolicy(ctx) + policy.freshness = takeRequestFreshness(&req) + if policy.freshness.err != nil { + return mcp.NewToolResultError(policy.freshness.err.Error()), nil + } + ctx = context.WithValue(ctx, sourceAckPolicyKey{}, policy) + } ctx, ack, owner := s.withEditAcknowledgement(ctx) if owner { defer func() { ack.finishReply(result, err) }() diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 273ff3fb3..9fab7c13b 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -1022,11 +1022,14 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ if guardErr := s.guardSymlinkWithinRepo(ctx, absPath); guardErr != nil { return mcp.NewToolResultError(guardErr.Error()), nil } - var err error - source, startLine, totalFileChars, err = s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines) + ns, err := s.readNodeSource(ctx, node, absPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read source: %v", err)), nil } + resolved := *node + resolved.StartLine, resolved.EndLine = ns.startLine, ns.endLine + node = &resolved + source, startLine, totalFileChars, _ = extractLinesFromContent(ns.content, ns.startLine, ns.endLine, contextLines) } compressBodies := req.GetBool("compress_bodies", false) @@ -1353,7 +1356,9 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest // Source code (optional). if includeSource && node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, node); err == nil { - if source, fromLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines); err == nil { + if ns, err := s.readNodeSource(ctx, node, absPath); err == nil { + source, fromLine, totalFileChars, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, contextLines) + entry["start_line"], entry["end_line"] = ns.startLine, ns.endLine entry["source"] = source entry["from_line"] = fromLine pending = append(pending, pendingSavings{ @@ -1719,7 +1724,8 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // 1. Get the example source. if node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, node); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, node, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) result["example_source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1767,7 +1773,9 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get the registration source (the caller function that wires this symbol). if cn.StartLine > 0 && cn.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, cn); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, cn.StartLine, cn.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, cn, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine entry["source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1797,7 +1805,9 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get test source. if tn.StartLine > 0 && tn.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, tn); err == nil { - if source, _, _, err := s.readLinesForCtx(ctx, absPath, tn.StartLine, tn.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, tn, absPath); err == nil { + source, _, _, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine entry["source"] = elideSourceForPattern(source, maxSourceLines) } } @@ -1847,6 +1857,7 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) ids[i] = strings.TrimSpace(ids[i]) } + gaps := s.laggingMutationReceipts(ctx) depth := req.GetInt("depth", 3) type editStep struct { @@ -1987,12 +1998,16 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) } } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + response := map[string]any{ "edit_order": editSteps, "test_after": testSteps, "total_files": len(steps), "summary": fmt.Sprintf("%d files to edit, %d test files to verify", len(editSteps), len(testSteps)), - }) + } + label := s.labelRelations(ctx, gaps, ids, nil) + label.apply(response) + res, err := s.respondJSONOrTOON(ctx, req, response) + return label.decorate(res), err } // extractPrefix returns the common prefix of a camelCase/PascalCase name. @@ -2301,7 +2316,9 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest (sym.Kind == graph.KindFunction || sym.Kind == graph.KindMethod) && sym.StartLine > 0 && sym.EndLine > 0 { if absPath, err := s.resolveNodePath(ctx, sym); err == nil { - if source, _, totalFileChars, err := s.readLinesForCtx(ctx, absPath, sym.StartLine, sym.EndLine, 0); err == nil { + if ns, err := s.readNodeSource(ctx, sym, absPath); err == nil { + source, _, totalFileChars, _ := extractLinesFromContent(ns.content, ns.startLine, ns.endLine, 0) + entry["start_line"] = ns.startLine if red, did := s.maybeRedactConfigLeaf(sym.Language, sym.FilePath, false, source); did { source = red } @@ -2741,6 +2758,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest dryRun := req.GetBool("dry_run", false) allowParseErrors := req.GetBool("allow_parse_errors", false) + gaps := s.laggingMutationReceipts(ctx) node := s.engineFor(ctx).GetSymbol(id) if node == nil { if recovery := s.unindexedRenameRecovery(ctx, id, newName, dryRun); recovery != nil { @@ -2903,27 +2921,80 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest } } + // Graph-backed edits in a pending file are provisional too: its current + // line can have a different binding even when the spelling still matches. + pendingFiles := make(map[string]bool, len(gaps)) + for _, gap := range gaps { + pendingFiles[s.repoRelative(gap.path)] = true + } + var verified, provisional []renameEdit + for _, edit := range edits { + if pendingFiles[edit.File] { + edit.Confidence = "provisional" + edit.Reason = "relations pending; verify this site manually" + provisional = append(provisional, edit) + } else { + verified = append(verified, edit) + } + } + for _, site := range s.provisionalSites(s.readerFor(ctx), gaps, node) { + key := fmt.Sprintf("%s:%d", site.File, site.Line) + if editSeen[key] { + continue + } + editSeen[key] = true + replacement, _ := rewrite(site.Text) + provisional = append(provisional, renameEdit{File: site.File, Line: site.Line, + OldText: site.Text, NewText: replacement, Confidence: "provisional", + Reason: "relations pending; verify this site manually"}) + } + // Only verified edits ever reach planRenameWrites and the commit loop. + edits = verified + reported := append(append([]renameEdit(nil), verified...), provisional...) + label := s.labelRelations(ctx, gaps, []string{id}, nil) + respond := func(payload map[string]any) (*mcp.CallToolResult, error) { + label.apply(payload) + res, err := s.respondJSONOrTOON(ctx, req, payload) + return label.decorate(res), err + } + // Collect affected files. fileSet := make(map[string]bool) for _, e := range edits { fileSet[e.File] = true } + reportedFiles := make(map[string]bool, len(fileSet)) + for _, edit := range reported { + reportedFiles[edit.File] = true + } resp := map[string]any{ "old_name": oldName, "new_name": newName, - "edits": edits, - "total_edits": len(edits), - "files_affected": len(fileSet), + "edits": reported, + "total_edits": len(reported), + "files_affected": len(reportedFiles), "dry_run": dryRun, } + if len(provisional) > 0 { + resp["provisional_site_count"] = len(provisional) + resp["manual_required"] = true + } + if !dryRun && pendingFiles[node.FilePath] { + resp["status"] = "pending_definition" + resp["written"] = false + return respond(resp) + } if len(edits) == 0 { // Nothing to write either way. Say so rather than reporting an // applied rename that touched no bytes. resp["status"] = "no_edits" + if len(provisional) > 0 { + resp["status"] = "provisional_only" + } resp["written"] = false - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } // Lock every affected path before verifying, so the check that each line @@ -2936,7 +3007,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest } absPaths = append(absPaths, abs) } - releaseMutation, lockErr := acquireMutationPaths(ctx, absPaths) + ctx, releaseMutation, lockErr := acquireMutationPaths(ctx, absPaths) if lockErr != nil { return mcp.NewToolResultError("rename cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -2951,10 +3022,10 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest resp["status"] = "would_apply" resp["written"] = false resp["files"] = previewRenameWrites(writes) - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } - results := s.commitRenameWrites(ctx, writes) + results := s.commitRenameWrites(ctx, writes, releaseMutation) written := 0 failed := 0 for _, r := range results { @@ -2965,6 +3036,9 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest failed++ } resp["status"] = "applied" + if len(provisional) > 0 { + resp["status"] = "applied_with_provisional" + } resp["written"] = written > 0 resp["files"] = results resp["files_written"] = written @@ -2978,7 +3052,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest resp["status"] = "failed" } } - return s.respondJSONOrTOON(ctx, req, resp) + return respond(resp) } func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -3024,7 +3098,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -3067,91 +3141,27 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) } } - fileStr := string(content) - - // Extract the symbol's source lines to verify old_source is within them. - lines := strings.Split(fileStr, "\n") - if node.StartLine > len(lines) || node.EndLine > len(lines) { - return mcp.NewToolResultError("symbol line range exceeds file length"), nil + start, end, locateErr := s.symbolLinesUnderLock(ctx, node, absPath, content) + if locateErr != nil { + return mcp.NewToolResultError(locateErr.Error()), nil } - - symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") - - if findEOLMatches(symbolSource, oldSource).count == 0 { - // Expand search to include preceding doc comments (agents often include - // them because get_symbol_source returns context_lines above the symbol). - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } - } - if expandedStart < node.StartLine-1 { - symbolSource = strings.Join(lines[expandedStart:node.EndLine], "\n") + resolved := *node + resolved.StartLine, resolved.EndLine = start, end + node = &resolved + fileStr := string(content) + edit, locateErr := locateSymbolEdit(fileStr, start, end, oldSource) + if locateErr != nil { + if errors.Is(locateErr, errEditFragmentRepeated) { + return mcp.NewToolResultError("old_source appears multiple times within the symbol. Provide a larger fragment to make it unique."), nil } - } - - srcMatches := findEOLMatches(symbolSource, oldSource) - if srcMatches.count == 0 { - return mcp.NewToolResultError(fmt.Sprintf( - "old_source not found within symbol %s (lines %d-%d). Use get_symbol_source to see the current code.", - id, node.StartLine, node.EndLine)), nil - } - - // Verify old_source is unique within the symbol. - if srcMatches.count > 1 { - return mcp.NewToolResultError( - "old_source appears multiple times within the symbol. Provide a larger fragment to make it unique."), nil - } - - // Apply the edit to the full file content. - // Find old_source within the symbol's line range only (not the whole file). - // Use the expanded start if doc comments were included. - effectiveStart := node.StartLine - if findEOLMatches(strings.Join(lines[node.StartLine-1:node.EndLine], "\n"), oldSource).count == 0 { - // Recalculate expanded start for offset computation. - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } + if errors.Is(locateErr, errEditFragmentNotFound) { + return mcp.NewToolResultError(fmt.Sprintf("old_source not found within symbol %s (lines %d-%d). Use get_symbol_source to see the current code.", id, start, end)), nil } - effectiveStart = expandedStart + 1 - } - - symbolStart := 0 - for i := 0; i < effectiveStart-1 && i < len(lines); i++ { - symbolStart += len(lines[i]) + 1 // +1 for newline + return mcp.NewToolResultError(locateErr.Error()), nil } - - symbolEnd := symbolStart + len(symbolSource) - if symbolEnd > len(fileStr) { - symbolEnd = len(fileStr) - } - - // Find old_source within the symbol region (EOL-tolerant: an - // LF-authored fragment still matches a CRLF region). Spans are byte - // offsets into the raw region, so the splice below always lands on the - // real on-disk bytes. - regionMatches := findEOLMatches(fileStr[symbolStart:symbolEnd], oldSource) - if len(regionMatches.spans) == 0 { - return mcp.NewToolResultError("old_source not found in symbol region"), nil - } - span := regionMatches.spans[0] - - // Build the new file content. - editStart := symbolStart + span.start - editEnd := symbolStart + span.end + editStart, editEnd := edit.start, edit.end effectiveNew := newSource - if regionMatches.normalized { + if edit.normalized { // Rewrite new_source's terminators to the matched region's own // style so the splice never introduces mixed line endings. effectiveNew = adaptToDominantEOL(newSource, fileStr[editStart:editEnd]) @@ -3177,7 +3187,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) "diff": unifiedDiff(node.FilePath, fileStr, newContent), "new_sha": gitBlobSHA(newContentBytes), } - if regionMatches.normalized { + if edit.normalized { preview["eol_normalized"] = true } return s.respondJSONOrTOON(ctx, req, preview) @@ -3202,7 +3212,9 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) sess.recordModified(node.FilePath) sess.recordSymbol(id) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) // Count lines changed. @@ -3218,7 +3230,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) "status": "applied", "new_sha": gitBlobSHA(newContentBytes), } - if regionMatches.normalized { + if edit.normalized { resp["eol_normalized"] = true } if reindexOutcome.Err != nil { diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 5132d62ee..1a711569b 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -1,6 +1,7 @@ package mcp import ( + "cmp" "context" "encoding/json" "errors" @@ -12,6 +13,7 @@ import ( "slices" "sort" "strings" + "sync" "time" "github.com/mark3labs/mcp-go/mcp" @@ -26,6 +28,7 @@ import ( "github.com/zzet/gortex/internal/graphpath" "github.com/zzet/gortex/internal/graphview" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/persistence" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/semantic" @@ -425,7 +428,13 @@ func (s *Server) handleVerifyChange(ctx context.Context, req mcp.CallToolRequest return mcp.NewToolResultError("changes array is empty"), nil } + gaps := s.laggingMutationReceipts(ctx) result := analysis.VerifyChanges(s.readerFor(ctx), s.engineFor(ctx), changes) + ids := make([]string, 0, len(changes)) + for _, change := range changes { + ids = append(ids, change.SymbolID) + } + label := s.labelRelations(ctx, gaps, ids, nil) if isCompact(req) { var b strings.Builder @@ -453,10 +462,11 @@ func (s *Server) handleVerifyChange(ctx context.Context, req mcp.CallToolRequest if result.Clean { fmt.Fprintf(&b, "clean: checked %d callers, %d implementors\n", result.CheckedCallers, result.CheckedImpls) } - return mcp.NewToolResultText(b.String()), nil + return label.decorate(mcp.NewToolResultText(b.String())), nil } - return s.respondJSONOrTOON(ctx, req, result) + res, err := s.respondJSONOrTOON(ctx, req, label.labelled(result)) + return label.decorate(res), err } // --------------------------------------------------------------------------- @@ -2574,6 +2584,7 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest opts.SkipCrossRepoNodes = true } + gaps := s.laggingMutationReceipts(ctx) reader := s.readerFor(ctx) opts.RepoPrefixes = s.deadCodeRepoPrefixes(ctx) if scope, active := s.scopeOptionsWithRepoNarrow(ctx, repoAllowFromContext(ctx)); active { @@ -2609,6 +2620,22 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest entries = kept } + // A pending definition or an unindexed caller cannot support a dead-code + // verdict. Keep those candidates separate in every output mode. + var label relationFreshness + var pendingUnknown []analysis.DeadCodeEntry + dead := make([]analysis.DeadCodeEntry, 0, len(entries)) + for _, entry := range entries { + candidate := s.labelRelations(ctx, gaps, []string{entry.ID}, nil) + if candidate.state != "" { + pendingUnknown = append(pendingUnknown, entry) + label = label.merge(candidate) + } else { + dead = append(dead, entry) + } + } + entries = dead + // Cap response size — large repos surface thousands of dead-code // candidates and the default JSON encoding spills past the MCP // per-response token cap. Callers that need the full list can @@ -2617,12 +2644,15 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest if v, ok := args["limit"].(float64); ok && v > 0 { limit = int(v) } - totalEntries := len(entries) - deadTruncated := false - if len(entries) > limit { - entries = entries[:limit] - deadTruncated = true + totalEntries, totalUnknown := len(entries), len(pendingUnknown) + if len(pendingUnknown) > limit { + pendingUnknown = pendingUnknown[:limit] + } + remaining := limit - len(pendingUnknown) + if len(entries) > remaining { + entries = entries[:remaining] } + deadTruncated := len(entries) < totalEntries || len(pendingUnknown) < totalUnknown variablesNote := buildDeadCodeNote(opts) @@ -2637,7 +2667,23 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest Line: e.Line, }) } - return s.gcxResponseWithBudget(req)(encodeAnalyze("dead_code", items)) + data, err := encodeAnalyze("dead_code", items) + if err != nil { + return nil, err + } + if len(pendingUnknown) > 0 { + unknown := make([]deadCodeItem, 0, len(pendingUnknown)) + for _, e := range pendingUnknown { + unknown = append(unknown, deadCodeItem{ID: e.ID, Kind: e.Kind, Name: e.Name, Path: e.FilePath, Line: e.Line}) + } + section, err := encodeAnalyze("pending_unknown", unknown) + if err != nil { + return nil, err + } + data = append(data, section...) + } + res, err := s.gcxResponseWithBudget(req)(data, nil) + return label.decorate(res), err } if isCompact(req) { @@ -2645,13 +2691,16 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest for _, e := range entries { fmt.Fprintf(&b, "%s %s %s:%d\n", e.Kind, e.ID, e.FilePath, e.Line) } - if len(entries) == 0 { + for _, e := range pendingUnknown { + fmt.Fprintf(&b, "pending_unknown %s %s %s:%d\n", e.Kind, e.ID, e.FilePath, e.Line) + } + if len(entries) == 0 && len(pendingUnknown) == 0 { b.WriteString("no dead code found\n") } if variablesNote != "" { fmt.Fprintf(&b, "\nnote: %s\n", variablesNote) } - return mcp.NewToolResultText(b.String()), nil + return label.decorate(mcp.NewToolResultText(b.String())), nil } result := map[string]any{ @@ -2659,13 +2708,19 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest "total": totalEntries, "truncated": deadTruncated, } + if totalUnknown > 0 { + result["pending_unknown"] = pendingUnknown + result["pending_unknown_count"] = totalUnknown + } if deadTruncated { result["limit"] = limit } if variablesNote != "" { result["note"] = variablesNote } - return s.respondJSONOrTOON(ctx, req, result) + label.apply(result) + res, err := s.respondJSONOrTOON(ctx, req, result) + return label.decorate(res), err } // buildDeadCodeNote summarises which low-signal kinds the analyzer @@ -2840,7 +2895,7 @@ type scaffoldReader struct { ctx context.Context } -func (r scaffoldReader) Graph() graph.Store { return r.s.graph } +func (r scaffoldReader) Graph() graph.Reader { return r.s.readerFor(r.ctx) } func (r scaffoldReader) ResolveFilePath(graphPath string) string { abs, err := r.s.resolveGraphPath(r.ctx, graphPath) if err != nil { @@ -2849,6 +2904,31 @@ func (r scaffoldReader) ResolveFilePath(graphPath string) string { return abs } +func (r scaffoldReader) ScaffoldNodeSource(node *graph.Node) (string, int, int, error) { + path := r.ResolveFilePath(node.FilePath) + if path == "" { + return "", 0, 0, fmt.Errorf("could not resolve %s", node.FilePath) + } + raw, err := os.ReadFile(path) + if err != nil { + return "", 0, 0, err + } + var start, end int + if mutationPathAdmitted(r.ctx, pathkey.MutationKey(path)) { + start, end, err = r.s.symbolLinesUnderLock(r.ctx, node, path, raw) + } else { + start, end, err = r.s.nodeLinesIn(r.ctx, node, raw) + } + if err != nil { + return "", 0, 0, err + } + lines := strings.Split(string(indexer.DecodeUTF16Source(raw)), "\n") + if start < 1 || end < start || end > len(lines) { + return "", 0, 0, fmt.Errorf("invalid current source range for %s", node.ID) + } + return strings.Join(lines[start-1:end], "\n"), start, end, nil +} + func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { exampleID, err := req.RequireString("id") if err != nil { @@ -2865,7 +2945,7 @@ func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (* dryRun = v } - result, err := analysis.GenerateScaffold(s.engine, scaffoldReader{s: s, ctx: ctx}, exampleID, newName) + result, err := analysis.GenerateScaffold(s.engineFor(ctx), scaffoldReader{s: s, ctx: ctx}, exampleID, newName) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -2877,44 +2957,71 @@ func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (* } if !dryRun && s.indexer != nil { - // Apply edits by writing files. Every write funnels through - // commitFileMutation — the UTF-16 refusal (#846), the cancellation - // gate, and the mutation receipt live there. A direct os.WriteFile - // used to splice UTF-8 into NUL-interleaved bytes and corrupt the - // file from the insertion point on. + paths := make([]string, 0, len(result.Edits)) for _, edit := range result.Edits { - absPath := edit.FilePath - if root := s.indexer.RootPath(); root != "" { - absPath = filepath.Join(root, edit.FilePath) + abs, err := s.resolveGraphPath(ctx, edit.FilePath) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - content, readErr := os.ReadFile(absPath) + paths = append(paths, abs) + } + ctx, release, err := acquireMutationPaths(ctx, paths) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + defer release() + // Rebuild under the complete source frontier: a queued edit must not + // resurrect an example removed while it waited for a source lock. + current, err := analysis.GenerateScaffold(s.engineFor(ctx), scaffoldReader{s: s, ctx: ctx}, exampleID, newName) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + grouped := make(map[string][]analysis.ScaffoldEdit) + for _, edit := range current.Edits { + path, err := s.resolveGraphPath(ctx, edit.FilePath) + if err != nil || !mutationPathAdmitted(ctx, pathkey.MutationKey(path)) { + return mcp.NewToolResultError("scaffold source paths changed while waiting; rebuild the preview"), nil + } + grouped[path] = append(grouped[path], edit) + } + var writes []committedSourceMutation + finish := sync.OnceFunc(func() { s.completeCommittedSourceMutations(ctx, writes, release) }) + defer finish() + ordered := slices.Sorted(maps.Keys(grouped)) + for _, path := range ordered { + edits := grouped[path] + content, readErr := os.ReadFile(path) if readErr != nil { - return mcp.NewToolResultError(fmt.Sprintf("could not read %s: %v", edit.FilePath, readErr)), nil + return mcp.NewToolResultError(fmt.Sprintf("could not read %s: %v", edits[0].FilePath, readErr)), nil } + // Apply all positions against one source version, from the bottom + // up, then parse and commit just one final candidate for this file. + slices.SortStableFunc(edits, func(a, b analysis.ScaffoldEdit) int { return cmp.Compare(b.InsertionLine, a.InsertionLine) }) lines := strings.Split(string(content), "\n") - insertIdx := max(edit.InsertionLine-1, 0) - insertIdx = min(insertIdx, len(lines)) - newLines := make([]string, 0, len(lines)+strings.Count(edit.Code, "\n")+2) - newLines = append(newLines, lines[:insertIdx]...) - newLines = append(newLines, "") - newLines = append(newLines, edit.Code) - newLines = append(newLines, lines[insertIdx:]...) - // Keep the file's existing mode: the atomic commit applies perm - // to the replacement, so a fixed 0o644 would strip a script's - // executable bit. + for _, edit := range edits { + at := min(max(edit.InsertionLine-1, 0), len(lines)) + lines = append(slices.Clone(lines[:at]), append([]string{"", edit.Code}, lines[at:]...)...) + } + after := []byte(strings.Join(lines, "\n")) + projection, gate := s.prepareAcknowledgedProjection(ctx, edits[0].FilePath, path, content, after) + if gate.Blocked { + return mcp.NewToolResultError("scaffold refused: candidate introduces syntax errors"), nil + } perm := os.FileMode(0o644) - if info, err := os.Stat(absPath); err == nil { + if info, statErr := os.Stat(path); statErr == nil { perm = info.Mode().Perm() } - commit, writeErr := s.commitFileMutation(ctx, "scaffold", "", "", edit.FilePath, absPath, - []byte(strings.Join(newLines, "\n")), perm) + commit, writeErr := s.commitFileMutation(ctx, "scaffold", "", "", edits[0].FilePath, path, + after, perm, sourceMutationOperation{kind: sourceWrite, projection: projection}) + if commit != nil && commit.snapshot().DiskStatus == mutationDiskCommitted { + writes = append(writes, captureCommittedSourceMutation(ctx, path, commit)) + } if writeErr != nil { - if errors.Is(writeErr, errMutationNotApplied) { - return mcp.NewToolResultError(mutationNotAppliedMessage("scaffold", commit, writeErr)), nil - } - return mcp.NewToolResultError(fmt.Sprintf("could not write %s: %v", edit.FilePath, writeErr)), nil + return mcp.NewToolResultError(mutationNotAppliedMessage("scaffold", commit, writeErr)), nil } } + finish() + resp["edits"], resp["notes"] = current.Edits, current.Notes resp["applied"] = true } @@ -4402,244 +4509,6 @@ func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) ( return s.handleAtomicBatchEdit(ctx, req) } -// applyBatchSymbolEdit applies one edit_symbol operation: it locates the -// symbol's source range, replaces old_source with new_source inside it, writes -// the file, and re-indexes. Semantics match the legacy single-op batch_edit. -func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { - res := batchEditResult{Op: "edit_symbol", SymbolID: edit.SymbolID} - if edit.OldSource == edit.NewSource { - res.Status, res.Error = "failed", "old_source and new_source are identical" - return res - } - node := s.engineFor(ctx).GetSymbol(edit.SymbolID) - if node == nil { - res.Status, res.Error = "failed", "symbol not found: "+edit.SymbolID - return res - } - res.FilePath = node.FilePath - if node.StartLine == 0 || node.EndLine == 0 { - res.Status, res.Error = "failed", "symbol has no line range" - return res - } - absPath, resolveErr := s.resolveNodePath(ctx, node) - if resolveErr != nil { - res.Status, res.Error = "failed", resolveErr.Error() - return res - } - if write { - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) - if lockErr != nil { - res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() - return res - } - defer releaseMutation() - } - content, readErr := os.ReadFile(absPath) - if readErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) - return res - } - // Same UTF-16 refusal as edit_symbol: the match below would fail - // with a misleading "not found" against NUL-interleaved bytes (#846). - if indexer.LooksUTF16Source(content) { - res.Status, res.Error = "failed", refuseUTF16Edit("batch edit_symbol", node.FilePath).Error() - return res - } - fileStr := string(content) - lines := strings.Split(fileStr, "\n") - - // Prefer the indexed symbol range, including preceding documentation. If a - // prior edit in this batch shifted line numbers while watcher reindex is - // pending, fall back only when old_source is unique in the current file. - regionMatches := findEOLMatches(fileStr, edit.OldSource) - symbolStart := 0 - rangeMatched := false - if node.StartLine <= len(lines) && node.EndLine <= len(lines) { - symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") - effectiveStart := node.StartLine - if findEOLMatches(symbolSource, edit.OldSource).count == 0 { - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break - } - } - if expandedStart < node.StartLine-1 { - expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") - if findEOLMatches(expanded, edit.OldSource).count > 0 { - symbolSource = expanded - effectiveStart = expandedStart + 1 - } - } - } - for i := 0; i < effectiveStart-1 && i < len(lines); i++ { - symbolStart += len(lines[i]) + 1 - } - symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) - candidate := findEOLMatches(fileStr[symbolStart:symbolEnd], edit.OldSource) - if candidate.count == 1 { - regionMatches = candidate - rangeMatched = true - } - } - if !rangeMatched { - symbolStart = 0 - switch regionMatches.count { - case 0: - res.Status, res.Error = "failed", "old_source not found within symbol or current file" - return res - case 1: - // Safe stale-range fallback. - default: - res.Status, res.Error = "failed", "symbol range is stale and old_source is not unique in the current file" - return res - } - } - span := regionMatches.spans[0] - editStart := symbolStart + span.start - editEnd := symbolStart + span.end - effectiveNew := edit.NewSource - if regionMatches.normalized { - effectiveNew = adaptToDominantEOL(edit.NewSource, fileStr[editStart:editEnd]) - res.EOLNormalized = true - } - newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] - if newContent == fileStr { - res.Status, res.Error = "failed", "old_source and new_source are identical after line-ending normalization" - return res - } - if !write { - res.Status = "validated" - return res - } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() - } - commit, writeErr := s.commitFileMutation(ctx, "batch_edit", "", "", node.FilePath, absPath, []byte(newContent), perm) - if writeErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) - return res - } - sess := s.sessionFor(ctx) - sess.recordModified(node.FilePath) - sess.recordSymbol(edit.SymbolID) - reindexOutcome := s.mutationReindexState(ctx, absPath) - commit.recordGraph(reindexOutcome) - res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending - res.ReindexReceipt = reindexOutcome.Receipt - res.ReindexGeneration = reindexOutcome.Generation - res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration - if reindexOutcome.Err != nil { - res.ReindexError = reindexOutcome.Err.Error() - } - res.Status = "applied" - return res -} - -// applyBatchFileEdit applies one edit_file operation: it replaces old_string -// with new_string in the file at path, mirroring edit_file's uniqueness and -// replace_all semantics, then re-indexes. -func (s *Server) applyBatchFileEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { - res := batchEditResult{Op: "edit_file", FilePath: edit.Path} - if edit.Path == "" { - res.Status, res.Error = "failed", "edit_file op requires path" - return res - } - if edit.OldString == edit.NewString { - res.Status, res.Error = "failed", "old_string and new_string are identical" - return res - } - absPath, relPath, resolveErr := s.resolveFilePath(ctx, edit.Path) - if resolveErr != nil { - res.Status, res.Error = "failed", resolveErr.Error() - return res - } - res.FilePath = relPath - if write { - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) - if lockErr != nil { - res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() - return res - } - defer releaseMutation() - } - content, readErr := os.ReadFile(absPath) - if readErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) - return res - } - // Same UTF-16 refusal as edit_file: the match below would fail with a - // misleading "not found" against NUL-interleaved bytes (#846). - if indexer.LooksUTF16Source(content) { - res.Status, res.Error = "failed", refuseUTF16Edit("batch edit_file", relPath).Error() - return res - } - fileStr := string(content) - matches := findEOLMatches(fileStr, edit.OldString) - count := matches.count - if count == 0 { - res.Status, res.Error = "failed", "old_string not found in file" - return res - } - if count > 1 && !edit.ReplaceAll { - res.Status, res.Error = "failed", fmt.Sprintf( - "old_string matches %d locations%s. Provide a larger fragment for uniqueness or set replace_all=true.", - count, matchSpansHint(fileStr, matches.spans)) - return res - } - var newContent string - switch { - case matches.normalized: - // The CRLF<->LF fallback matched: splice the real byte spans and - // write new_string with each region's own line terminators so the - // edit never introduces mixed endings. - limit := 1 - if edit.ReplaceAll { - limit = -1 - } - newContent = spliceSpansEOL(fileStr, matches.spans, edit.NewString, limit) - if newContent == fileStr { - res.Status, res.Error = "failed", "old_string and new_string are identical after line-ending normalization" - return res - } - res.EOLNormalized = true - case edit.ReplaceAll: - newContent = strings.ReplaceAll(fileStr, edit.OldString, edit.NewString) - default: - newContent = strings.Replace(fileStr, edit.OldString, edit.NewString, 1) - } - if !write { - res.Status = "validated" - return res - } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() - } - commit, writeErr := s.commitFileMutation(ctx, "batch_edit", "", "", relPath, absPath, []byte(newContent), perm) - if writeErr != nil { - res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) - return res - } - s.sessionFor(ctx).recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) - commit.recordGraph(reindexOutcome) - res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending - res.ReindexReceipt = reindexOutcome.Receipt - res.ReindexGeneration = reindexOutcome.Generation - res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration - if reindexOutcome.Err != nil { - res.ReindexError = reindexOutcome.Err.Error() - } - res.Status = "applied" - return res -} - // --------------------------------------------------------------------------- // handleContracts — unified dispatcher for contracts (replaces 2 tools) // --------------------------------------------------------------------------- diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 77b52c8ea..08c18acb8 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -925,7 +925,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -1016,8 +1016,9 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* allowParseErrors := req.GetBool("allow_parse_errors", false) var gate parseGateResult + var prepared *preparedFileProjection if parseGateEnabled() { - gate = s.checkAcknowledgedParseGate(ctx, relPath, content, newContentBytes) + prepared, gate = s.prepareAcknowledgedProjection(ctx, relPath, absPath, content, newContentBytes) if gate.Blocked && !allowParseErrors && !dryRun { return mcp.NewToolResultError(parseGateError(relPath, gate)), nil } @@ -1051,7 +1052,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if info, err := os.Stat(absPath); err == nil { perm = info.Mode().Perm() } - commit, writeErr := s.commitFileMutation(ctx, "edit_file", mutationID, fingerprint, relPath, absPath, newContentBytes, perm) + commit, writeErr := s.commitFileMutation(ctx, "edit_file", mutationID, fingerprint, relPath, absPath, newContentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("edit", commit, writeErr)), nil @@ -1065,7 +1066,9 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) resp := map[string]any{ @@ -1119,7 +1122,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } - releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + ctx, releaseMutation, lockErr := acquireMutationPath(ctx, absPath) if lockErr != nil { return mcp.NewToolResultError("write cancelled while waiting for exclusive file access: " + lockErr.Error()), nil } @@ -1191,8 +1194,9 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( allowParseErrors := req.GetBool("allow_parse_errors", false) var gate parseGateResult + var prepared *preparedFileProjection if parseGateEnabled() { - gate = s.checkAcknowledgedParseGate(ctx, relPath, priorContent, contentBytes) + prepared, gate = s.prepareAcknowledgedProjection(ctx, relPath, absPath, priorContent, contentBytes) if gate.Blocked && !allowParseErrors && !dryRun { return mcp.NewToolResultError(parseGateError(relPath, gate)), nil } @@ -1224,7 +1228,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( return s.respondJSONOrTOON(ctx, req, preview) } - commit, writeErr := s.commitFileMutation(ctx, "write_file", mutationID, fingerprint, relPath, absPath, contentBytes, perm) + commit, writeErr := s.commitFileMutation(ctx, "write_file", mutationID, fingerprint, relPath, absPath, contentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("write", commit, writeErr)), nil @@ -1238,7 +1242,9 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexOutcome := s.mutationReindexState(ctx, absPath) + admission := s.admitSourceMutations(ctx, absPath) + releaseMutation() + reindexOutcome := s.awaitSourceMutations(ctx, admission) commit.recordGraph(reindexOutcome) resp := map[string]any{ diff --git a/internal/mcp/tools_move_inline.go b/internal/mcp/tools_move_inline.go index c08e7ffce..b9bc1169d 100644 --- a/internal/mcp/tools_move_inline.go +++ b/internal/mcp/tools_move_inline.go @@ -17,7 +17,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" "golang.org/x/tools/go/ast/astutil" - "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/graph" ) @@ -378,42 +377,77 @@ func (s *Server) handleMoveSymbol(ctx context.Context, req mcp.CallToolRequest) return s.respondJSONOrTOON(ctx, req, resp) } + plans := []plannedSource{{path: srcAbs, before: srcContent, existed: true}, {path: tgtAbs, before: tgtContent, existed: tgtExists}} + for _, edit := range callerEdits { + plans = append(plans, plannedSource{path: edit.absPath, before: edit.origContent, existed: true}) + } + ctx, release, lockErr := lockPlannedSources(ctx, plans) + if lockErr != nil { + return mcp.NewToolResultError(lockErr.Error()), nil + } + defer release() + // Preserve the move's existing terminal write sequence after the complete + // CAS/cancellation boundary; do not strand its target half on cancellation. + ctx = context.WithoutCancel(ctx) + physical := make(map[string]*mutationCommitRecord, len(plans)) measurement := s.beginMoveObservation(ctx, id, resp["target_id"].(string), srcAbs, tgtAbs, srcRel, tgtRelOut, samePackage && len(callerEdits) == 0) defer measurement.finishWrites(false) + var targets []sourceMutationTarget + completed := false + finish := func() { + if completed { + return + } + completed = true + // Writes remain target-first. Preserve the embedded adapter's + // historical source-before-target reconcile order after unlocking. + if len(targets) >= 2 && targets[0].path == tgtAbs && targets[1].path == srcAbs { + targets[0], targets[1] = targets[1], targets[0] + } + admissions := s.admitSourceMutationFrontier(ctx, targets) + for i, target := range targets { + measurement.observeAdmission(target.path, &admissions[i]) + } + release() + for i, outcome := range s.awaitSourceMutationFrontier(ctx, admissions) { + physical[targets[i].path].recordGraph(outcome) + } + } + defer finish() // Admit an already-committed subset even after a later I/O fault. + write := func(path string, content []byte) error { + record, err := measurement.write(ctx, s, path, content) + if record != nil { + physical[path] = record + if err == nil { + targets = append(targets, captureSourceMutationTarget(ctx, path)) + } + } + return err + } // Apply writes atomically. Order: target first (so file exists for any // caller that triggers a follow-up open), then source, then callers. - if err := measurement.write(editAcknowledgementFrom(ctx, s), tgtAbs, newTgtContent); err != nil { + if err := write(tgtAbs, newTgtContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, tgtContent, newTgtContent, tgtExists, true) - if err := measurement.write(editAcknowledgementFrom(ctx, s), srcAbs, newSrcContent); err != nil { + if err := write(srcAbs, newSrcContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, srcContent, newSrcContent, true, true) for _, ce := range callerEdits { - if err := measurement.write(editAcknowledgementFrom(ctx, s), ce.absPath, ce.newContent); err != nil { + if err := write(ce.absPath, ce.newContent); err != nil { return mcp.NewToolResultError(err.Error()), nil } observeEditSourceReplacement(ctx, ce.origContent, ce.newContent, true, true) } measurement.finishWrites(true) observeEditSourceComplete(ctx) + finish() - // Re-index every touched file so the graph picks up the new location. sess := s.sessionFor(ctx) for _, t := range touched { sess.recordModified(t.Path) } - if measurement != nil { - _ = measurement.reindex(ctx, srcAbs) - _ = measurement.reindex(ctx, tgtAbs) - } else { - _ = s.reindexFile(srcAbs) - _ = s.reindexFile(tgtAbs) - } - for _, ce := range callerEdits { - _ = s.reindexFile(ce.absPath) - } if measurement != nil { resp["move_observation_id"] = measurement.id @@ -464,25 +498,15 @@ func buildMovedSymbolID(s *Server, tgtAbs, name string) string { return rel + "::" + name } -func writeMoveFile(absPath string, content []byte) error { - return writeMoveFileObserved(nil, absPath, content) -} - -func writeMoveFileObserved(a *editAcknowledgement, absPath string, content []byte) error { +func (s *Server) writeMoveFile(ctx context.Context, tool, absPath string, content []byte) (*mutationCommitRecord, error) { perm := os.FileMode(0o644) if info, err := os.Stat(absPath); err == nil { perm = info.Mode().Perm() } if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { - return fmt.Errorf("could not create parent dir for %s: %w", absPath, err) + return nil, fmt.Errorf("could not create parent dir for %s: %w", absPath, err) } - lap := beginEditAcknowledgementLap(a, editAckDiskWrite) - err := agents.AtomicWriteFile(absPath, content, perm) - lap.finish() - if err != nil { - return fmt.Errorf("could not write %s: %w", absPath, err) - } - return nil + return s.commitFileMutation(ctx, tool, "", "", s.repoRelative(absPath), absPath, content, perm) } // extractGoTopLevelDecl finds the top-level declaration named `name` inside @@ -1243,18 +1267,41 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest }) } - // Optionally delete the callee. + // A caller in the callee's own file is one physical source plan. Remove + // the declaration from the rewritten candidate, never from its old bytes. var calleeNewContent []byte - calleeDeleted := false + calleeDeleted, calleeInCaller := false, false if deleteAfter { - spliced, derr := spliceOutGoDecl(calleeContent, calleeFset, calleeDocless) - if derr != nil { - return mcp.NewToolResultError(derr.Error()), nil + for i := range patches { + if patches[i].absPath != calleeAbs { + continue + } + patchedFset := token.NewFileSet() + patchedFile, parseErr := parser.ParseFile(patchedFset, calleeAbs, patches[i].patched, parser.ParseComments) + if parseErr != nil { + return mcp.NewToolResultError(parseErr.Error()), nil + } + _, declaration, locateErr := findGoFunc(patchedFile, node.Name) + if locateErr != nil { + return mcp.NewToolResultError(locateErr.Error()), nil + } + spliced, spliceErr := spliceOutGoDecl(patches[i].patched, patchedFset, declaration) + if spliceErr != nil { + return mcp.NewToolResultError(spliceErr.Error()), nil + } + patches[i].patched = spliced + calleeInCaller = true + break + } + if !calleeInCaller { + spliced, spliceErr := spliceOutGoDecl(calleeContent, calleeFset, calleeDocless) + if spliceErr != nil { + return mcp.NewToolResultError(spliceErr.Error()), nil + } + calleeNewContent = spliced } - calleeNewContent = spliced calleeDeleted = true } - type touched struct { Path string `json:"path"` Role string `json:"role"` @@ -1274,7 +1321,7 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest ImportRem: p.removedImp, }) } - if calleeDeleted { + if calleeDeleted && !calleeInCaller { touchedList = append(touchedList, touched{ Path: s.repoRelative(calleeAbs), Role: "callee_deleted", @@ -1302,25 +1349,59 @@ func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest return s.respondJSONOrTOON(ctx, req, resp) } - for _, p := range patches { - if err := writeMoveFile(p.absPath, p.patched); err != nil { + plans := make([]plannedSource, 0, len(patches)+1) + for _, patch := range patches { + plans = append(plans, plannedSource{path: patch.absPath, before: patch.orig, existed: true}) + } + if calleeDeleted && !calleeInCaller { + plans = append(plans, plannedSource{path: calleeAbs, before: calleeContent, existed: true}) + } + ctx, release, lockErr := lockPlannedSources(ctx, plans) + if lockErr != nil { + return mcp.NewToolResultError(lockErr.Error()), nil + } + defer release() + ctx = context.WithoutCancel(ctx) + physical := make(map[string]*mutationCommitRecord, len(plans)) + var targets []sourceMutationTarget + completed := false + finish := func() { + if completed { + return + } + completed = true + admissions := s.admitSourceMutationFrontier(ctx, targets) + release() + for i, outcome := range s.awaitSourceMutationFrontier(ctx, admissions) { + physical[targets[i].path].recordGraph(outcome) + } + } + defer finish() + + for _, patch := range patches { + record, err := s.writeMoveFile(ctx, "inline_symbol", patch.absPath, patch.patched) + if err != nil { return mcp.NewToolResultError(err.Error()), nil } + physical[patch.absPath] = record + targets = append(targets, captureSourceMutationTarget(ctx, patch.absPath)) } - if calleeDeleted { - if err := writeMoveFile(calleeAbs, calleeNewContent); err != nil { + if calleeDeleted && !calleeInCaller { + record, err := s.writeMoveFile(ctx, "inline_symbol", calleeAbs, calleeNewContent) + if err != nil { return mcp.NewToolResultError(err.Error()), nil } + physical[calleeAbs] = record + targets = append(targets, captureSourceMutationTarget(ctx, calleeAbs)) } + finish() sess := s.sessionFor(ctx) for _, p := range patches { sess.recordModified(s.repoRelative(p.absPath)) - _ = s.reindexFile(p.absPath) } - if calleeDeleted { + if calleeDeleted && !calleeInCaller { sess.recordModified(s.repoRelative(calleeAbs)) - _ = s.reindexFile(calleeAbs) } return s.respondJSONOrTOON(ctx, req, resp) diff --git a/internal/mcp/tools_mutation_status.go b/internal/mcp/tools_mutation_status.go index cb5bed005..eb3ebabfa 100644 --- a/internal/mcp/tools_mutation_status.go +++ b/internal/mcp/tools_mutation_status.go @@ -206,6 +206,7 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] s.refreshPendingSourceRecord(record) snap := record.snapshot() payload := map[string]any{ + "ack_mode": snap.AckMode, "found": true, "receipt": snap.Receipt, "tool": snap.Tool, @@ -220,6 +221,9 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] if snap.MutationID != "" { payload["mutation_id"] = snap.MutationID } + if snap.ContentSHA256 != "" { + payload["content_sha256"] = snap.ContentSHA256 + } if snap.NewSHA != "" { payload["new_sha"] = snap.NewSHA } @@ -252,11 +256,16 @@ func (s *Server) mutationStatusPayload(record *mutationCommitRecord) map[string] acknowledgement = &snapshot } attachAcknowledgementPayload(payload, acknowledgement, "unavailable_recording_or_evicted") - if value, found := s.mutationReceipts.Load(snap.ReindexReceipt); found { + publicationReceipt := snap.ReindexReceipt + if publicationReceipt == "" { + publicationReceipt = snap.publicationReceipt + } + if value, found := s.mutationReceipts.Load(publicationReceipt); found { if receipt, ok := value.(*mutationReceipt); ok { attachReceiptTiers(payload, receipt) } } + record.attachFileProjection(payload) return payload } @@ -391,6 +400,9 @@ func mutationCommitListingPayload(records []*mutationCommitRecord) []map[string] "graph_status_terminal": graphStatusTerminal(snap.GraphStatus, snap.GraphRecorded), } attachMutationRefreshSnapshot(entry, snap) + if snap.AckMode != "" { + entry["ack_mode"] = snap.AckMode + } // Every remaining field mirrors mutationCommitSnapshot's omitempty // exactly. Rendering by hand is what dropped new_sha and // bytes_written the first time, so the parity is asserted against the @@ -405,6 +417,9 @@ func mutationCommitListingPayload(records []*mutationCommitRecord) []map[string] if snap.MutationID != "" { entry["mutation_id"] = snap.MutationID } + if snap.ContentSHA256 != "" { + entry["content_sha256"] = snap.ContentSHA256 + } if snap.NewSHA != "" { entry["new_sha"] = snap.NewSHA } diff --git a/internal/mcp/tools_overlay_branch.go b/internal/mcp/tools_overlay_branch.go index fddc5c4e2..7b1e52ea4 100644 --- a/internal/mcp/tools_overlay_branch.go +++ b/internal/mcp/tools_overlay_branch.go @@ -9,7 +9,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" @@ -259,13 +258,45 @@ func (s *Server) handleOverlayMerge(ctx context.Context, req mcp.CallToolRequest } var writer daemon.DiskWriteFn + var writes []committedSourceMutation + release := func() {} if toDisk { - // Capture the resolver closure once; each per-file write - // re-resolves, re-checks drift, and atomically writes. - writer = s.diskWriteForMerge(ctx) + // Resolve and own the complete bounded file set before taking the + // branch mutex. The manager then verifies that exact source cohort + // before the first callback, without waiting for publication inside it. + files, err := s.overlays.FilesForBranchBounded(id, from, overlayRequestSnapshotMaxFiles, overlayRequestSnapshotMaxBytes) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + opts.ExpectedFiles = append(make([]daemon.OverlayFile, 0, len(files)), files...) + paths := make([]string, 0, len(files)) + for _, file := range files { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + paths = append(paths, path) + } + ctx, release, err = acquireMutationPaths(ctx, paths) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + defer release() + writer = func(file daemon.OverlayFile) error { + write, err := s.writeOverlayMergeFileHeld(ctx, file) + if write.record != nil { + writes = append(writes, write) + } + return err + } } res, mergeErr := s.overlays.MergeBranches(id, opts, writer) + if toDisk { + // The branch mutex is now released, including on a later-file error. + // Every successfully committed file still receives its one admission. + s.completeCommittedSourceMutations(ctx, writes, release) + } if mergeErr != nil { switch { case errors.Is(mergeErr, daemon.ErrSessionNotFound): @@ -321,60 +352,56 @@ func (s *Server) handleOverlayMerge(ctx context.Context, req mcp.CallToolRequest // reuse, drift detection would be duplicated and could silently // diverge across surfaces. func (s *Server) diskWriteForMerge(ctx context.Context) daemon.DiskWriteFn { - return func(f daemon.OverlayFile) error { - absPath, _, resolveErr := s.resolveFilePath(ctx, f.Path) - if resolveErr != nil { - return fmt.Errorf("merge to_disk: %s: %w", f.Path, resolveErr) + return func(file daemon.OverlayFile) error { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return fmt.Errorf("merge to_disk: %s: %w", file.Path, err) } - if f.Deleted { - // Tombstone-on-disk: remove the file. Honour the - // drift guard before removal so a concurrent edit - // can't be silently overwritten by an unlink. - if f.BaseSHA != "" { - current, readErr := os.ReadFile(absPath) - if readErr != nil { - // File already gone is not drift; surface - // the error only when something other than - // "not found" is at play. - if errors.Is(readErr, os.ErrNotExist) { - return nil - } - return readErr - } - if gitBlobSHA(current) != normalizeExpectedSHA(f.BaseSHA) { - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } - } - if rmErr := os.Remove(absPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { - return fmt.Errorf("merge to_disk: remove %s: %w", absPath, rmErr) - } - s.reindexFile(absPath) - return nil + held, release, err := acquireMutationPath(ctx, path) + if err != nil { + return err } - if f.BaseSHA != "" { - current, readErr := os.ReadFile(absPath) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return readErr - } - if readErr == nil && gitBlobSHA(current) != normalizeExpectedSHA(f.BaseSHA) { - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } - if readErr != nil && f.BaseSHA != "" { - // File expected on disk but missing — treat as - // drift so the caller re-reads + resubmits. - return fmt.Errorf("%s: %w", f.Path, daemon.ErrOverlayDrift) - } + defer release() + write, err := s.writeOverlayMergeFileHeld(held, file) + if write.record != nil { + s.completeCommittedSourceMutations(held, []committedSourceMutation{write}, release) + } + return err + } +} + +// Only physical I/O belongs in the manager callback; admission and graph waits +// belong to the operation's final frontier after the manager releases its mutex. +func (s *Server) writeOverlayMergeFileHeld(ctx context.Context, file daemon.OverlayFile) (committedSourceMutation, error) { + path, _, err := s.resolveFilePath(ctx, file.Path) + if err != nil { + return committedSourceMutation{}, fmt.Errorf("merge to_disk: %s: %w", file.Path, err) + } + if file.BaseSHA != "" { + current, readErr := os.ReadFile(path) + if file.Deleted && errors.Is(readErr, os.ErrNotExist) { + return committedSourceMutation{}, nil } - perm := os.FileMode(0o644) - if info, statErr := os.Stat(absPath); statErr == nil { - perm = info.Mode().Perm() + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return committedSourceMutation{}, readErr } - if err := agents.AtomicWriteFile(absPath, []byte(f.Content), perm); err != nil { - return fmt.Errorf("merge to_disk: write %s: %w", absPath, err) + if readErr != nil || gitBlobSHA(current) != normalizeExpectedSHA(file.BaseSHA) { + return committedSourceMutation{}, fmt.Errorf("%s: %w", file.Path, daemon.ErrOverlayDrift) } - s.reindexFile(absPath) - return nil } + perm := os.FileMode(0o644) + if info, statErr := os.Stat(path); statErr == nil { + perm = info.Mode().Perm() + } + operation := sourceMutationOperation{kind: sourceWrite} + if file.Deleted { + operation.kind = sourceRemove + } + commit, err := s.commitFileMutation(ctx, "overlay_merge", "", "", file.Path, path, []byte(file.Content), perm, operation) + if err != nil { + return committedSourceMutation{}, fmt.Errorf("merge to_disk: %s: %w", file.Path, err) + } + return captureCommittedSourceMutation(ctx, path, commit), nil } // handleCompareBranches runs the supplied query kind against two diff --git a/internal/mcp/tools_safe_delete.go b/internal/mcp/tools_safe_delete.go index 08ac47ad4..402bc5993 100644 --- a/internal/mcp/tools_safe_delete.go +++ b/internal/mcp/tools_safe_delete.go @@ -5,12 +5,14 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" ) // registerSafeDeleteSymbolTool wires safe_delete_symbol — atomic @@ -118,7 +120,21 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq for _, site := range provisional { refs = append(refs, safeDeleteReference{Kind: "provisional", FilePath: site.File, Line: site.Line}) } + var finishWrites func() + var primaryAbs string + var primaryCommit *mutationCommitRecord + var primaryOutcome mutationReindexOutcome respond := func(result map[string]any) (*mcp.CallToolResult, error) { + if finishWrites != nil { + finishWrites() + } + if primaryCommit != nil { + if primaryOutcome.Err != nil { + result["reindex_error"] = primaryOutcome.Err.Error() + } + s.attachMutationFreshness(result, node.FilePath, primaryAbs, primaryOutcome) + attachMutationCommit(result, primaryCommit) + } label := s.labelRelations(ctx, gaps, []string{id}, nil) label.apply(result) res, err := s.respondJSONOrTOON(ctx, req, result) @@ -129,8 +145,14 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq // refusing. Builds a per-caller plan, applies the removable standalone // calls (parse-gate validated), and flags embedded references as manual. var appliedPatches, failedPatches []callerPatch + var propagationPlan []callerPatch if req.GetBool("propagate", false) && len(refs) > 0 { - plan := s.buildPropagationPlan(ctx, g, node) + var plan []callerPatch + if dryRun { + plan = s.buildPropagationPreview(ctx, g, node) + } else { + plan = s.buildPropagationPlan(ctx, g, node) + } for _, site := range provisional { reason := "provisional: named in a file whose edit the graph has not read yet — patch by hand" if site.Line == 0 { @@ -142,16 +164,25 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq } manual := countManual(plan) if dryRun { + needsRevalidation := false + for _, patch := range plan { + needsRevalidation = needsRevalidation || patch.NeedsSourceRevalidation + } + hint := "re-run with dry_run=false to apply the verified remove_line patches and delete the symbol; resolve manual patches first or pass force=true" + if needsRevalidation { + hint = "this is a source preview, not write authorization: publish or revalidate the changed source and scoped references before requesting apply" + } return respond(map[string]any{ - "status": "propagation_plan", - "symbol": id, - "file": node.FilePath, - "caller_patches": plan, - "removable": len(plan) - manual, - "manual": manual, - "reference_count": len(refs), - "dry_run": true, - "hint": "re-run with dry_run=false to apply the remove_line patches and delete the symbol; resolve manual patches first or pass force=true", + "status": "propagation_plan", + "source_revalidation_required": needsRevalidation, + "symbol": id, + "file": node.FilePath, + "caller_patches": plan, + "removable": len(plan) - manual, + "manual": manual, + "reference_count": len(refs), + "dry_run": true, + "hint": hint, }) } if manual > 0 && !force { @@ -163,38 +194,10 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq "hint": fmt.Sprintf("%d call site(s) need manual patching; resolve them or pass force=true to delete anyway", manual), }) } - applied, failed, perr := s.applyRemoveLinePatches(plan) - if perr != nil { - return mcp.NewToolResultError(perr.Error()), nil - } - appliedPatches, failedPatches = applied, failed - if len(failed) > 0 && !force { - return respond(map[string]any{ - "status": "propagation_partial", - "symbol": id, - "patches_applied": applied, - "patches_failed": failed, - "hint": "some removals would break their file's syntax and were skipped; patch them by hand or pass force=true to delete the symbol anyway", - }) - } - // Callers patched — proceed with the deletion. Re-fetch the node in - // case a same-file removal shifted its line range. - force = true - node = g.GetNode(id) - if node == nil { - return respond(map[string]any{ - "status": "deleted_by_propagation", - "symbol": id, - "patches_applied": appliedPatches, - "note": "the symbol's definition was removed as a side effect of patching (it no longer resolves in the graph)", - }) - } - if node.StartLine == 0 || node.EndLine == 0 { - return mcp.NewToolResultError("symbol has no line range after propagation: " + id), nil - } + propagationPlan = plan } - if len(refs) > 0 && !force { + if len(refs) > 0 && !force && len(propagationPlan) == 0 { return respond(map[string]any{ "status": "rejected_has_references", "symbol": id, @@ -270,14 +273,24 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq return mcp.NewToolResultError(refuseUTF16Edit("safe_delete_symbol", node.FilePath).Error()), nil } lines := strings.Split(string(content), "\n") - if node.StartLine > len(lines) || node.EndLine > len(lines) { - return mcp.NewToolResultError("symbol line range exceeds file length"), nil + if dryRun { + start, end, err := s.nodeLinesIn(ctx, node, content) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resolved := *node + resolved.StartLine, resolved.EndLine = start, end + node = &resolved + } + // An actual delete resolves again only under its complete held path set. + // These initial response fields are replaced by the applied snapshot. + deleteStart, deleteEnd, linesDeleted := 0, 0, 0 + deletedChunk := "" + if node.StartLine >= 1 && node.EndLine >= node.StartLine && node.EndLine <= len(lines) { + deleteStart, deleteEnd = expandDeleteRange(node, lines) + deletedChunk = strings.Join(lines[deleteStart-1:deleteEnd], "\n") + linesDeleted = deleteEnd - deleteStart + 1 } - - deleteStart, deleteEnd := expandDeleteRange(node, lines) - - deletedChunk := strings.Join(lines[deleteStart-1:deleteEnd], "\n") - linesDeleted := deleteEnd - deleteStart + 1 result := map[string]any{ "symbol": id, @@ -333,29 +346,119 @@ func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolReq } } - deletedIDs, err := s.applyPendingDeletes(ctx, pending) + // One final union lock covers every source effect. A wait behind another + // writer cannot silently reuse the old reference/closure/patch authority. + paths := make([]string, 0, len(pending)+len(propagationPlan)) + for _, item := range pending { + paths = append(paths, item.abs) + } + for _, patch := range propagationPlan { + if patch.Action == "remove_line" { + paths = append(paths, patch.abs) + } + } + ctx, releasePaths, lockErr := acquireMutationPaths(ctx, paths) + if lockErr != nil { + return mcp.NewToolResultError("lock delete frontier: " + lockErr.Error()), nil + } + defer releasePaths() + primaryAbs = absPath + primaryKey := pathkey.MutationKey(absPath) + var writes []committedSourceMutation + finished := false + finishWrites = func() { + if finished { + return + } + finished = true + final, outcomes := s.completeCommittedSourceMutations(ctx, writes, releasePaths) + for i, write := range final { + if write.target.key == primaryKey { + primaryCommit, primaryOutcome = write.record, outcomes[i] + } + } + } + defer finishWrites() + currentNode := g.GetNode(id) + if currentNode == nil || currentNode.FilePath != node.FilePath { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "the selected declaration changed while acquiring the complete source frontier; request a new plan"}) + } + currentGaps := s.laggingMutationReceipts(ctx) + currentProvisional := s.provisionalSites(g, currentGaps, currentNode) + currentRefs := collectReferencingEdges(g, id) + for _, site := range currentProvisional { + currentRefs = append(currentRefs, safeDeleteReference{Kind: "provisional", FilePath: site.File, Line: site.Line}) + } + if !sameSafeDeleteReferences(refs, currentRefs) || !reflect.DeepEqual(provisional, currentProvisional) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "references or unpublished source changed while acquiring the complete source frontier; request a new plan"}) + } + if cascadeMode != cascadeModeOff { + currentClosure, truncated := computeCascadeClosure(g, currentNode, cascadeIntoTests) + if truncated != cascadeTruncated || !reflect.DeepEqual(closure, currentClosure) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "cascade membership changed while acquiring the complete source frontier; request a new plan"}) + } + for _, member := range pending[1:] { + if len(s.provisionalSites(g, currentGaps, member.node)) > 0 { + return respond(map[string]any{"status": "rejected_has_references", "symbol": id, "written": false, "hint": "a cascade member gained an unpublished reference; publish it and request a new plan"}) + } + } + } + if len(propagationPlan) > 0 { + // Original provisional rows stay manual. Compare exact graph-derived + // occurrences and accepted source identities before the first write. + currentPlan := s.buildPropagationPlan(ctx, g, currentNode) + var originalGraphPlan []callerPatch + for _, patch := range propagationPlan { + if patch.kind != "" { + originalGraphPlan = append(originalGraphPlan, patch) + } + } + if !reflect.DeepEqual(originalGraphPlan, currentPlan) { + return respond(map[string]any{"status": "source_authority_changed", "symbol": id, "written": false, "hint": "caller occurrences or accepted bytes changed while acquiring the complete source frontier; request a new plan"}) + } + var callerWrites []committedSourceMutation + var propagationErr error + appliedPatches, failedPatches, callerWrites, propagationErr = s.applyRemoveLinePatchesHeld(ctx, propagationPlan) + writes = append(writes, callerWrites...) + if propagationErr != nil || len(failedPatches) > 0 && !force { + partial := map[string]any{"status": "propagation_partial", "symbol": id, "patches_applied": appliedPatches, "patches_failed": failedPatches, "hint": "some source patches committed but the complete removal did not finish; inspect these exact effects before retrying"} + if propagationErr != nil { + partial["error"] = propagationErr.Error() + } + return respond(partial) + } + refs = referencesAfterCommittedPatches(refs, appliedPatches, id) + if len(refs) > 0 && !force { + return respond(map[string]any{"status": "rejected_has_references", "symbol": id, "references": refs, "reference_count": len(refs), "patches_applied": appliedPatches, "patches_failed": failedPatches, "hint": "unplanned or unverified references remain; patch them or explicitly request force"}) + } + result["references"], result["reference_count"] = refs, len(refs) + } + deletedIDs, definitionWrites, err := s.applyPendingDeletesHeld(ctx, pending) + writes = append(writes, definitionWrites...) if err != nil { - // Fail-fast: surface what was done up to this point so the - // caller can recover. Treat the failure as a tool error. + if len(deletedIDs) == 0 && len(appliedPatches) == 0 { + return mcp.NewToolResultError(err.Error()), nil + } + // Surface only source effects whose physical commit succeeded. result["status"] = "partial_failure" result["cascade_deleted"] = deletedIDs result["error"] = err.Error() + result["patches_applied"], result["patches_failed"] = appliedPatches, failedPatches return respond(result) } + result["start_line"], result["end_line"] = pending[0].start, pending[0].end + result["lines_deleted"] = pending[0].end - pending[0].start + 1 + result["preview"] = pending[0].preview + // Persist session state and re-index every touched file. sess := s.sessionFor(ctx) - touchedFiles := make(map[string]struct{}, len(pending)) for _, p := range pending { sess.recordModified(p.node.FilePath) sess.recordSymbol(p.node.ID) if s.symHistory != nil { s.symHistory.Record(p.node.ID, true) } - touchedFiles[p.abs] = struct{}{} - } - for abs := range touchedFiles { - s.reindexFile(abs) } result["status"] = "deleted" @@ -386,14 +489,14 @@ func normaliseCascadeMode(raw string) string { } // pendingDelete buffers everything needed to delete one symbol from -// its file. start/end of zero mean "compute lazily from the node and -// the file contents" — only the original target arrives pre-computed -// because the dry_run preview already needed those numbers. +// its file. Every span is resolved against the bytes read under the held +// path set; the response retains that actual removed snapshot. type pendingDelete struct { - node *graph.Node - abs string - start int - end int + node *graph.Node + abs string + start int + end int + preview string } // applyPendingDeletes groups pending deletes by absolute file path @@ -402,7 +505,7 @@ type pendingDelete struct { // rewritten once. Returns the IDs of symbols whose bytes were // removed; on first error, the partial list rides alongside the // error. -func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDelete) ([]string, error) { +func (s *Server) applyPendingDeletesHeld(ctx context.Context, pending []*pendingDelete) ([]string, []committedSourceMutation, error) { byFile := map[string][]*pendingDelete{} order := []string{} for _, p := range pending { @@ -413,28 +516,29 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele } deleted := make([]string, 0, len(pending)) + var writes []committedSourceMutation for _, abs := range order { bucket := byFile[abs] content, err := os.ReadFile(abs) if err != nil { - return deleted, fmt.Errorf("could not read %s: %v", abs, err) + return deleted, writes, fmt.Errorf("could not read %s: %v", abs, err) } // Refuse UTF-16 sources before splicing raw bytes (#846) — this is // the shared delete writer, so cascade targets are covered too. if indexer.LooksUTF16Source(content) { - return deleted, refuseUTF16Edit("safe_delete_symbol", abs) + return deleted, writes, refuseUTF16Edit("safe_delete_symbol", abs) } lines := strings.Split(string(content), "\n") - // Materialise ranges for entries that arrived with zero - // start/end (the cascade tail; the original target arrives - // pre-computed for parity with the dry_run preview). for _, p := range bucket { - if p.start == 0 || p.end == 0 { - if p.node.StartLine > len(lines) || p.node.EndLine > len(lines) { - return deleted, fmt.Errorf("symbol %s line range exceeds file %s", p.node.ID, abs) - } - p.start, p.end = expandDeleteRange(p.node, lines) + start, end, err := s.symbolLinesUnderLock(ctx, p.node, abs, content) + if err != nil { + return deleted, writes, err } + resolved := *p.node + resolved.StartLine, resolved.EndLine = start, end + p.node = &resolved + p.start, p.end = expandDeleteRange(p.node, lines) + p.preview = strings.Join(lines[p.start-1:p.end], "\n") } // Sort descending so deletions earlier in the file don't // shift the line indexes of later ones. @@ -448,13 +552,12 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele // failure surface clean. for i := 0; i < len(bucket)-1; i++ { if bucket[i].start <= bucket[i+1].end { - return deleted, fmt.Errorf("overlapping delete ranges in %s for %s and %s", + return deleted, writes, fmt.Errorf("overlapping delete ranges in %s for %s and %s", abs, bucket[i].node.ID, bucket[i+1].node.ID) } } for _, p := range bucket { lines = append(lines[:p.start-1], lines[p.end:]...) - deleted = append(deleted, p.node.ID) } newContent := strings.Join(lines, "\n") // The UTF-16 refusal above is the early, per-file UX; the commit @@ -477,11 +580,16 @@ func (s *Server) applyPendingDeletes(ctx context.Context, pending []*pendingDele if info, err := os.Stat(abs); err == nil { perm = info.Mode().Perm() } - if _, werr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", rel, abs, []byte(newContent), perm); werr != nil { - return deleted, fmt.Errorf("could not write %s: %v", abs, werr) + commit, werr := s.commitFileMutation(ctx, "safe_delete_symbol", "", "", rel, abs, []byte(newContent), perm) + if werr != nil { + return deleted, writes, fmt.Errorf("could not write %s: %v", abs, werr) + } + writes = append(writes, captureCommittedSourceMutation(ctx, abs, commit)) + for _, p := range bucket { + deleted = append(deleted, p.node.ID) } } - return deleted, nil + return deleted, writes, nil } // expandDeleteRange grows a symbol's [StartLine, EndLine] range to @@ -707,15 +815,17 @@ func collectReferencingEdges(g graph.Reader, id string) []safeDeleteReference { if !isReferencingEdgeKind(e.Kind) { continue } - key := e.From + "|" + string(e.Kind) + key := fmt.Sprintf("%s|%s|%s|%d", e.From, e.Kind, e.FilePath, e.Line) if seen[key] { continue } seen[key] = true - row := safeDeleteReference{FromID: e.From, Kind: string(e.Kind)} + row := safeDeleteReference{FromID: e.From, Kind: string(e.Kind), Line: e.Line, FilePath: e.FilePath} if from := g.GetNode(e.From); from != nil { row.FromName = from.Name - row.FilePath = from.FilePath + if row.FilePath == "" { + row.FilePath = from.FilePath + } } out = append(out, row) } @@ -753,3 +863,37 @@ func isCommentLine(trimmed string) bool { } return false } + +func sameSafeDeleteReferences(a, b []safeDeleteReference) bool { + if len(a) != len(b) { + return false + } + counts := make(map[safeDeleteReference]int, len(a)) + for _, ref := range a { + counts[ref]++ + } + for _, ref := range b { + if counts[ref] == 0 { + return false + } + counts[ref]-- + } + return true +} + +func referencesAfterCommittedPatches(refs []safeDeleteReference, applied []callerPatch, target string) []safeDeleteReference { + var remaining []safeDeleteReference + for _, ref := range refs { + removed := false + for _, patch := range applied { + if patch.sourceHash != ([32]byte{}) && patch.target == target && patch.from == ref.FromID && string(patch.kind) == ref.Kind && patch.File == ref.FilePath && patch.Line == ref.Line && patch.Line > 0 { + removed = true + break + } + } + if !removed { + remaining = append(remaining, ref) + } + } + return remaining +} diff --git a/internal/mcp/tools_safe_delete_test.go b/internal/mcp/tools_safe_delete_test.go index 6d740a552..eb1fe80ca 100644 --- a/internal/mcp/tools_safe_delete_test.go +++ b/internal/mcp/tools_safe_delete_test.go @@ -11,13 +11,16 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "go.uber.org/zap" ) // newSafeDeleteTestServer builds a server with one indexed file // containing two symbols: Live (called by Caller) and DeadCode -// (unused). resolveNodePath uses the indexer root, so the test -// server fakes an indexer with the temp root. +// (unused). The absolute graph-path fixture has a real parser-owning indexer +// so current-byte declaration resolution does not trust its manual spans. func newSafeDeleteTestServer(t *testing.T) (*Server, string) { t.Helper() @@ -58,7 +61,10 @@ func Caller() int { }) g.AddEdge(&graph.Edge{From: abs + "::Caller", To: abs + "::Live", Kind: graph.EdgeCalls}) + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + idx.SetRootPath(dir) s := &Server{ + indexer: idx, graph: g, session: newSessionState(), tokenStats: &tokenStats{}, diff --git a/internal/mcp/view_answer_identity_test.go b/internal/mcp/view_answer_identity_test.go index 96f989256..58f64e11e 100644 --- a/internal/mcp/view_answer_identity_test.go +++ b/internal/mcp/view_answer_identity_test.go @@ -294,7 +294,7 @@ func TestMutationReceiptRendersPublicationPhases(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) mutation.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/mcp/view_mutation_known_write_test.go b/internal/mcp/view_mutation_known_write_test.go index 043f7bfb1..df220cb7b 100644 --- a/internal/mcp/view_mutation_known_write_test.go +++ b/internal/mcp/view_mutation_known_write_test.go @@ -56,7 +56,7 @@ func TestEditAdmissionHandsTheCommittedWriteToTheCapture(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - _, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + _, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) inner.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/mcp/view_mutation_state_test.go b/internal/mcp/view_mutation_state_test.go index a66adb586..229b07a12 100644 --- a/internal/mcp/view_mutation_state_test.go +++ b/internal/mcp/view_mutation_state_test.go @@ -188,11 +188,11 @@ func TestCheckoutMutationResolvedRootRejectsFoldedDistinctDirectory(t *testing.T func TestCheckoutMutationPathLockRefusesOtherCheckout(t *testing.T) { root := t.TempDir() ctx := withCheckoutMutation(context.Background(), &fakeCheckoutMutationLifecycle{}, root) - release, err := acquireMutationPath(ctx, filepath.Join(t.TempDir(), "file.go")) + _, release, err := acquireMutationPath(ctx, filepath.Join(t.TempDir(), "file.go")) if err == nil || release != nil { t.Fatalf("lock acquired outside selected checkout: release=%v err=%v", release != nil, err) } - release, err = acquireMutationPath(ctx, filepath.Join(root, "file.go")) + _, release, err = acquireMutationPath(ctx, filepath.Join(root, "file.go")) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func TestCheckoutMutationOutsideFinalSymlinkPreserved(t *testing.T) { }} ctx := withCheckoutMutation(context.Background(), lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "link.go", outsideLink, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "link.go", outsideLink, []byte("package changed\n"), 0o644) if !errors.Is(err, errMutationNotApplied) || prepared || record.diskStatus() != mutationDiskNotApplied { t.Fatalf("outside link was admitted: prepared=%v receipt=%+v err=%v", prepared, record.snapshot(), err) } @@ -265,7 +265,7 @@ func TestCheckoutMutationCommitPreparesBeforeDisk(t *testing.T) { }} ctx := withCheckoutMutation(context.Background(), lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) if err != nil || !prepared || record.diskStatus() != mutationDiskCommitted { t.Fatalf("commit prepared=%v record=%+v err=%v", prepared, record.snapshot(), err) } @@ -295,7 +295,7 @@ func TestCheckoutMutationPrepareFailureLeavesDiskUntouched(t *testing.T) { }} ctx = withCheckoutMutation(ctx, lease, root) s := &Server{} - record, err := s.commitFileMutation(ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "test", "file.go", path, []byte("package changed\n"), 0o644) if !errors.Is(err, errMutationNotApplied) || record.diskStatus() != mutationDiskNotApplied { t.Fatalf("expected not-applied receipt: %+v, %v", record.snapshot(), err) } diff --git a/internal/mcp/view_mutation_write_intent.go b/internal/mcp/view_mutation_write_intent.go index a10bfb24a..8f2ba578d 100644 --- a/internal/mcp/view_mutation_write_intent.go +++ b/internal/mcp/view_mutation_write_intent.go @@ -7,10 +7,9 @@ import ( type sourceMutationWriteIntentKey struct{} -// sourceMutationWriteIntent belongs to one handler. Publication waits suspend -// its store announcement; mutation lane/cycle admission and disk writes retain -// ordinary writer preemption. The selected route is still revalidated by the -// existing mutation admission after each wait. +// sourceMutationWriteIntent belongs to one store-writing checkout handler. +// Publication waits suspend its announcement. The selected route is still +// revalidated by mutation admission after each wait. type sourceMutationWriteIntent struct { mu sync.Mutex announce func() func() @@ -24,6 +23,21 @@ func (s *Server) withSourceMutationWriteIntent(ctx context.Context, tool string) s.materializer == nil || s.materializer.Store == nil { return ctx, func() {} } + // Canonical source edits only write the working file; the watcher owns + // its store writes. Checkout mutation/capture paths do write the view + // store and retain foreground preemption during their admission. + view := requestViewFromContext(ctx) + storeWriting := checkoutMutationFromContext(ctx) != nil || + (view.routed() && view.rider != nil && view.rider.Exact && view.viewRoot != "" && + view.files == nil && view.materialized != nil && s.lifecycle != nil) + if !storeWriting { + return ctx, func() {} + } + switch tool { + case "edit_file", "write_file", "edit_symbol", "batch_edit": + default: + return ctx, func() {} + } intent := &sourceMutationWriteIntent{announce: s.materializer.Store.AnnounceWrite} intent.release = intent.announce() return context.WithValue(ctx, sourceMutationWriteIntentKey{}, intent), intent.close diff --git a/internal/mcp/view_mutation_write_intent_test.go b/internal/mcp/view_mutation_write_intent_test.go index f3572e9db..9684f34f7 100644 --- a/internal/mcp/view_mutation_write_intent_test.go +++ b/internal/mcp/view_mutation_write_intent_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/graphview" ) func TestSourceMutationWriteIntentRestoresAdmissionAndCancelsWait(t *testing.T) { @@ -49,3 +50,19 @@ func TestSourceMutationWriteIntentRestoresAdmissionAndCancelsWait(t *testing.T) resume() require.False(t, store.WriteWanted(), "cleanup cannot be undone by a late wait return") } + +func TestSelectedCheckoutSourceMutationStillAnnouncesStoreIntent(t *testing.T) { + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "checkout-intent.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + srv, root := setupTestServer(t) + srv.materializer = &graphview.Materializer{Store: store} + selected := withCheckoutMutation(t.Context(), &fakeCheckoutMutationLifecycle{}, root) + _, release := srv.withSourceMutationWriteIntent(selected, "edit_file") + require.True(t, store.WriteWanted(), "a selected store-writing checkout retains foreground preemption") + release() + require.False(t, store.WriteWanted()) + _, releaseRead := srv.withSourceMutationWriteIntent(selected, "get_symbol") + defer releaseRead() + require.False(t, store.WriteWanted(), "read requests never acquire a writer intent") +} diff --git a/internal/mcp/view_publication_binding_test.go b/internal/mcp/view_publication_binding_test.go index 7535615a0..18f4c23f2 100644 --- a/internal/mcp/view_publication_binding_test.go +++ b/internal/mcp/view_publication_binding_test.go @@ -66,7 +66,7 @@ func TestMutationPublicationRecordIsBoundBeforeAdmissionReturns(t *testing.T) { ctx, _ = withMutationCommitNote(ctx) ctx = withCheckoutMutation(ctx, mutation, filepath.Dir(path)) data := []byte("package committed\n") - record, err := s.commitFileMutation(ctx, "write_file", "", "", "edit.go", path, data, 0o600) + record, err := s.commitHeldFileMutationForTest(t, ctx, "write_file", "", "", "edit.go", path, data, 0o600) require.NoError(t, err) sum := sha256.Sum256(data) inner.ticket.ContentHash = hex.EncodeToString(sum[:]) diff --git a/internal/parser/crashpool/crashpool.go b/internal/parser/crashpool/crashpool.go index c7248ced2..df1466e6c 100644 --- a/internal/parser/crashpool/crashpool.go +++ b/internal/parser/crashpool/crashpool.go @@ -44,11 +44,12 @@ type extractRequest struct { // extractResponse is the worker → parent reply for one request. type extractResponse struct { - Seq uint64 - Nodes []*graph.Node - Edges []*graph.Edge - ParseErrors int - HasParseErr bool + Seq uint64 + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool + HasParseErr bool // Err is non-empty when the extractor returned an error or // panicked. Panicked distinguishes a recovered Go panic (the // worker survives) from a plain error return. @@ -58,10 +59,11 @@ type extractResponse struct { // Result is the parent-side outcome of one Submit call. type Result struct { - Nodes []*graph.Node - Edges []*graph.Edge - ParseErrors int - HasParseErr bool + Nodes []*graph.Node + Edges []*graph.Edge + ParseErrors int + ParseChecked bool + HasParseErr bool // Crashed is true when the worker subprocess died or hung // (SIGSEGV / OOM / kill / deadline). The file should be // quarantined and skipped. diff --git a/internal/parser/crashpool/pool.go b/internal/parser/crashpool/pool.go index 6d293ad5d..db88e109d 100644 --- a/internal/parser/crashpool/pool.go +++ b/internal/parser/crashpool/pool.go @@ -215,11 +215,12 @@ func (p *Pool) SubmitWithOptions(relPath, language string, content []byte, tempo return Result{Panicked: true, Err: resp.Err} } return Result{ - Nodes: resp.Nodes, - Edges: resp.Edges, - ParseErrors: resp.ParseErrors, - HasParseErr: resp.HasParseErr, - Err: resp.Err, + Nodes: resp.Nodes, + Edges: resp.Edges, + ParseErrors: resp.ParseErrors, + ParseChecked: resp.Err == "" && resp.ParseChecked && resp.ParseErrors >= 0, + HasParseErr: resp.HasParseErr, + Err: resp.Err, } case <-timer.C: // Worker hung. Killing it unblocks the roundTrip goroutine diff --git a/internal/parser/crashpool/pool_test.go b/internal/parser/crashpool/pool_test.go index f02b4680d..b940e0aa4 100644 --- a/internal/parser/crashpool/pool_test.go +++ b/internal/parser/crashpool/pool_test.go @@ -1,6 +1,7 @@ package crashpool import ( + "bytes" "encoding/gob" "fmt" "os" @@ -187,6 +188,7 @@ func TestPool_CrashIsolation(t *testing.T) { bad := p.Submit("dir/BOOM.mock", "mock", []byte("x")) require.True(t, bad.Crashed) + require.False(t, bad.ParseChecked) require.True(t, bad.Bad()) require.Contains(t, bad.Err, "crashed") @@ -222,6 +224,7 @@ func TestPool_RequestTimeout(t *testing.T) { p := newTestPool(t, "slow", Config{Workers: 1, RequestTimeout: 250 * time.Millisecond}) res := p.Submit("slow.mock", "mock", []byte("x")) require.True(t, res.Crashed) + require.False(t, res.ParseChecked) require.Contains(t, res.Err, "timed out") } @@ -266,3 +269,38 @@ func TestPool_RoutesWorkerStderrThroughLogger(t *testing.T) { require.Contains(t, lines, "worker stderr line 1") require.Contains(t, lines, "worker stderr line 2") } + +// Old workers and plugin extractors have no full-parse proof; the additive +// wire field must remain unknown instead of certifying their zero default. +func TestParseVerdictOldWorkerAndPluginRemainUnknown(t *testing.T) { + var wire bytes.Buffer + old := struct { + Seq uint64 + ParseErrors int + HasParseErr bool + }{Seq: 9} + require.NoError(t, gob.NewEncoder(&wire).Encode(old)) + var response extractResponse + require.NoError(t, gob.NewDecoder(&wire).Decode(&response)) + require.False(t, response.ParseChecked) + require.Zero(t, response.ParseErrors) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "mock", RelPath: "x.mock"}).ParseChecked) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "mockpanic", RelPath: "x.mp"}).ParseChecked) + require.False(t, serveOne(mockRegistry(), extractRequest{Language: "unknown"}).ParseChecked) +} + +func TestPoolNativeSyntaxVerdictRoundTrip(t *testing.T) { + p := newTestPool(t, "real", Config{Workers: 1}) + for _, source := range []string{"package p\nfunc Good() {}\n", "@@@ <<<<<\n"} { + result := p.Submit("main.go", "go", []byte(source)) + require.False(t, result.Bad(), result.Err) + require.True(t, result.ParseChecked) + if source[0] == '@' { + require.Positive(t, result.ParseErrors) + require.True(t, result.HasParseErr) + } else { + require.Zero(t, result.ParseErrors) + require.False(t, result.HasParseErr) + } + } +} diff --git a/internal/parser/crashpool/worker.go b/internal/parser/crashpool/worker.go index 58bf4c819..f26cb9094 100644 --- a/internal/parser/crashpool/worker.go +++ b/internal/parser/crashpool/worker.go @@ -115,6 +115,7 @@ func serveOne(reg *parser.Registry, req extractRequest) (resp extractResponse) { resp.Edges = nil resp.Err = fmt.Sprintf("extractor panic: %v\n%s", rec, debug.Stack()) resp.Panicked = true + resp.ParseChecked, resp.ParseErrors = false, 0 } }() @@ -132,13 +133,15 @@ func serveOne(reg *parser.Registry, req extractRequest) (resp extractResponse) { if result != nil { resp.Nodes = result.Nodes resp.Edges = result.Edges - if result.Tree != nil { - if result.Tree.HasParseErrors() { - resp.HasParseErr = true - resp.ParseErrors = result.Tree.CountParseErrors() - } - result.Tree.Release() + if result.ParseChecked && result.ParseErrors >= 0 { + resp.ParseChecked, resp.ParseErrors = true, result.ParseErrors + resp.HasParseErr = result.ParseErrors > 0 + } else if result.Tree != nil && result.Tree.HasParseErrors() { + // Legacy/plugin trees retain their old error metadata, but absence + // of an explicit full-parse proof cannot become checked zero. + resp.HasParseErr, resp.ParseErrors = true, result.Tree.CountParseErrors() } + result.ReleaseTree() } return resp } diff --git a/internal/parser/extractor.go b/internal/parser/extractor.go index 67d816c84..6b8143ad7 100644 --- a/internal/parser/extractor.go +++ b/internal/parser/extractor.go @@ -171,9 +171,14 @@ func AssetClassOf(e Extractor) AssetClass { // own trees internally — the contract pipeline degrades to its regex // fallback for those languages. type ExtractionResult struct { - Nodes []*graph.Node - Edges []*graph.Edge - Tree *ParseTree + // ParseChecked is a successful full native parse's explicit syntax + // verdict. A zero error count without this bit is unknown, never clean. + // Producers set both scalars before releasing their native tree. + ParseChecked bool + ParseErrors int + Nodes []*graph.Node + Edges []*graph.Edge + Tree *ParseTree // ConstValues carries the literal value of each KindConstant node // whose RHS is a string / numeric literal, for the indexer to persist // in the queryable constant_values sidecar (kept out of the gob Meta diff --git a/internal/parser/languages/c.go b/internal/parser/languages/c.go index d540dfed9..6a0ad1aaf 100644 --- a/internal/parser/languages/c.go +++ b/internal/parser/languages/c.go @@ -126,7 +126,8 @@ func (e *CExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRes defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 338891db3..cc9da7d58 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -375,9 +375,9 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // don't defer Close — closing happens in the indexer after the // contract pass releases its ref. pt := parser.NewParseTree(tree, src, "go") - result := &parser.ExtractionResult{Tree: pt} - root := tree.RootNode() + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{Tree: pt, ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, @@ -447,10 +447,14 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // No-op (the package name is not currently surfaced as a node). case m.Captures["func.def"] != nil: - e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + root.WithScratch(func() { + e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + }) case m.Captures["method.def"] != nil: - e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + root.WithScratch(func() { + e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) + }) case m.Captures["typedef.def"] != nil: e.emitTypeDecl(m, filePath, fileID, src, result, seenTypeName) @@ -1278,14 +1282,14 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // override method emits as a separate KindMethod node, so this // post-pass runs after every node in the file has been emitted and // rewires any convention-derived EdgeModelsTable to the literal. - rewireORMTableNameOverrides(root, src, result) + root.WithScratch(func() { rewireORMTableNameOverrides(root, src, result) }) // WebSocket upgrade handlers → real-time endpoint edges. emitGoWebSocketEdges(src, filePath, result) // Same-file constant/variable value references → impact-radius reads. - captureValueRefCandidates(result, root, filePath, src) - captureFnValueCandidates(result, root, filePath, src) + root.WithScratch(func() { captureValueRefCandidates(result, root, filePath, src) }) + root.WithScratch(func() { captureFnValueCandidates(result, root, filePath, src) }) // Gin middleware-chain dispatcher + route registrations → resolver hints. mineGinMiddleware(src, result) @@ -1294,10 +1298,10 @@ func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parse // map key/value, channel element. These positions are dropped by the // bare-name type passes, so without this find_usages of a type misses // every place it appears only as an argument/element type. - emitGoTypeArgReferences(root, src, filePath, fileID, funcRanges, result) + root.WithScratch(func() { emitGoTypeArgReferences(root, src, filePath, fileID, funcRanges, result) }) // GoFrame reflective routes: g.Meta-tagged request structs → controllers. - captureGoFrameRoutes(result, root, filePath, src) + root.WithScratch(func() { captureGoFrameRoutes(result, root, filePath, src) }) return result, nil } diff --git a/internal/parser/languages/java.go b/internal/parser/languages/java.go index 382058721..cbb0d4ab7 100644 --- a/internal/parser/languages/java.go +++ b/internal/parser/languages/java.go @@ -184,7 +184,8 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/parse_health_test.go b/internal/parser/languages/parse_health_test.go new file mode 100644 index 000000000..4bada88ec --- /dev/null +++ b/internal/parser/languages/parse_health_test.go @@ -0,0 +1,68 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/astquery" + "github.com/zzet/gortex/internal/parser" + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +func TestNativeExtractionCarriesCheckedSyntaxVerdict(t *testing.T) { + for _, test := range []struct { + language, path, clean string + extractor parser.Extractor + }{ + {"go", "source.go", "package p\nfunc Good() {}\n", NewGoExtractor()}, + {"python", "source.py", "def good():\n return 1\n", NewPythonExtractor()}, + {"typescript", "source.ts", "export function good(): number { return 1; }\n", NewTypeScriptExtractor()}, + {"c", "source.c", "int good(void) { return 1; }\n", NewCExtractor()}, + {"java", "P.java", "class P { int good() { return 1; } }\n", NewJavaExtractor()}, + {"rust", "source.rs", "fn good() -> i32 { 1 }\n", NewRustExtractor()}, + } { + for _, source := range []string{test.clean, "@@@ <<<<<\n"} { + t.Run(test.language+map[bool]string{true: "/clean", false: "/errors"}[source == test.clean], func(t *testing.T) { + raw := []byte(source) + tree, err := parser.ParseFile(raw, astquery.DefaultLanguageResolver(test.language)) + require.NoError(t, err) + proof := parser.NewParseTree(tree, raw, test.language) + defer proof.Release() + count := proof.CountParseErrors() + if source == test.clean { + require.Zero(t, count) + } else { + require.Positive(t, count) + } + result, err := test.extractor.Extract(test.path, raw) + require.NoError(t, err) + defer result.ReleaseTree() + require.True(t, result.ParseChecked, "unknown default cannot certify a clean projection") + require.Equal(t, count, result.ParseErrors, "ERROR/MISSING count must match the original syntax gate") + }) + } + } +} + +func TestPinnedGrammarHasErrorIncludesMissingNode(t *testing.T) { + src := []byte("int good(void) { return 1 }\n") + tree, err := parser.ParseFile(src, astquery.DefaultLanguageResolver("c")) + require.NoError(t, err) + defer tree.Close() + root := tree.RootNode() + missing := false + var visit func(*sitter.Node) + visit = func(node *sitter.Node) { + if node == nil { + return + } + missing = missing || node.IsMissing() + for i := 0; i < int(node.ChildCount()); i++ { + visit(node.Child(i)) + } + } + visit(root) + require.True(t, missing, "fixture must contain a real pinned grammar MISSING node") + require.True(t, root.HasError(), "the clean fast path may skip walks only when HasError is false") +} diff --git a/internal/parser/languages/python.go b/internal/parser/languages/python.go index 4926d8b37..eb66b20d2 100644 --- a/internal/parser/languages/python.go +++ b/internal/parser/languages/python.go @@ -154,7 +154,8 @@ func (e *PythonExtractor) Extract(filePath string, src []byte) (*parser.Extracti defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/rust.go b/internal/parser/languages/rust.go index 245028695..c1b6ddacb 100644 --- a/internal/parser/languages/rust.go +++ b/internal/parser/languages/rust.go @@ -177,7 +177,8 @@ func (e *RustExtractor) Extract(filePath string, src []byte) (*parser.Extraction defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/languages/typescript.go b/internal/parser/languages/typescript.go index b2f7878e4..aa9405e0b 100644 --- a/internal/parser/languages/typescript.go +++ b/internal/parser/languages/typescript.go @@ -186,7 +186,8 @@ func (e *TypeScriptExtractor) Extract(filePath string, src []byte) (*parser.Extr defer tree.Close() root := tree.RootNode() - result := &parser.ExtractionResult{} + checked, parseErrors := parser.ParseErrorVerdict(root) + result := &parser.ExtractionResult{ParseChecked: checked, ParseErrors: parseErrors} fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, diff --git a/internal/parser/parsetree.go b/internal/parser/parsetree.go index 25d41c16f..7065ab1b4 100644 --- a/internal/parser/parsetree.go +++ b/internal/parser/parsetree.go @@ -140,11 +140,24 @@ func (pt *ParseTree) CountParseErrors() int { if root == nil { return 0 } - count := 0 - walkParseErrors(root, &count) + _, count := ParseErrorVerdict(root) return count } +// ParseErrorVerdict captures the syntax verdict of a successful full parse +// while its root is live. The clean path is one native HasError query; only +// error trees are walked. A missing root is unknown rather than checked zero. +func ParseErrorVerdict(root *sitter.Node) (checked bool, count int) { + if root == nil { + return false, 0 + } + if !root.HasError() { + return true, 0 + } + walkParseErrors(root, &count) + return true, count +} + func walkParseErrors(n *sitter.Node, count *int) { if n == nil { return diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index 0f69aad0a..f4440c943 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -855,6 +855,7 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { } scopeIntentDefaults := conf.Scope.IntentDefaults multiOpts := []gortexmcp.MultiRepoOptions{{ + EditAckMode: conf.Edit.Ack, MultiIndexer: mi, ConfigManager: cm, CheckoutLifecycle: s.CheckoutLifecycle, From 595ad6db26b0ce1d01fdfa01c15edf364184a4eb Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 01:22:53 +0100 Subject: [PATCH 102/138] bench: qualify queued move ACK and write observations without publication credit --- bench/load/edit_probe_move.go | 96 +++++++++++--- bench/load/edit_probe_move_queued_test.go | 153 ++++++++++++++++++++++ 2 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 bench/load/edit_probe_move_queued_test.go diff --git a/bench/load/edit_probe_move.go b/bench/load/edit_probe_move.go index 1100b5371..632cd5135 100644 --- a/bench/load/edit_probe_move.go +++ b/bench/load/edit_probe_move.go @@ -134,6 +134,8 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP } out.RPCID = "rpc-" + strings.TrimPrefix(refs.OperationID, "move-") ackHash, session := "", "" + queuedRows := 0 + queuedWaitRequired, queuedWaitMeasured := false, false for _, ref := range refs.Files { row := statuses[ref.Role] file := editProbeMoveFileAccounting{Path: ref.Path, Role: ref.Role, Receipt: ref.Receipt} @@ -146,7 +148,13 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if row["move_write_verdict"] != "all_writes_completed" || row["file_write_status"] != "committed" || row["writer_return_observed"] != true { fail("move source writes not confirmed") } - if row["requested_background_state"] != "not_applicable_direct_reconcile" || row["requested_background"] != nil { + queued := row["direct_reconcile_state"] == "unavailable_queued_publication" || row["read_assisted_reconcile_state"] == "unavailable_queued_publication" + if queued { + queuedRows++ + if row["direct_reconcile_state"] != "unavailable_queued_publication" || row["read_assisted_reconcile_state"] != "unavailable_queued_publication" || row["direct_reconcile"] != nil || row["read_assisted_reconcile"] != nil || row["requested_background"] != nil { + fail("queued move contradicts unavailable publication windows") + } + } else if row["requested_background_state"] != "not_applicable_direct_reconcile" || row["requested_background"] != nil { fail("move direct route/background applicability mismatch") } blob, _ := row["new_sha"].(string) @@ -173,6 +181,16 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP for _, name := range []string{"caller_result", "disk_write", "edit_control", "middleware", "parse", "publish", "reindex_wait", "reply_prepare"} { value, present := phases[name] if name == "publish" || name == "reindex_wait" { + if queued { + if !moveQueuedAcknowledgementPhase(row, name, value, present, states[name], applicability[name], counts[name]) { + fail("queued move scheduler A phase unavailable: " + name) + } + if name == "reindex_wait" { + queuedWaitRequired = queuedWaitRequired || applicability[name] == "applicable" + queuedWaitMeasured = queuedWaitMeasured || value != nil + } + continue + } count, _ := counts[name].(map[string]any) started, se := strconvEditProbeUint(count["started"]) completed, ce := strconvEditProbeUint(count["completed"]) @@ -200,6 +218,25 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if timing["scope"] == "move_file_write" && timing["clock"] == "monotonic" && timing["state"] == "measured" && timing["outcome"] == "committed" && se == nil && re == nil && we == nil && returned >= start && wall == returned-start { file.WriteStartedNS, file.WriteReturnedNS, file.WriteWindowNS = &start, &returned, &wall } + if out.A != nil && returned > out.A.WallNS { + fail("move write exceeds actual acknowledgement bounds") + } + original, _ := row["move_request_cause"].(map[string]any) + originalSession, _ := original["session"].(string) + originalKey, _ := original["request_id_key"].(string) + if originalSession == "" || originalKey != "n:"+strconv.FormatInt(wireID, 10) { + fail("original move wire cause unavailable") + } + if session == "" { + session = originalSession + } else if session != originalSession { + fail("move per-file original causes came from different sessions") + } + if queued { + file.Route = "queued_publication_ack_write_only" + out.Files = append(out.Files, file) + continue + } route, scope := "direct_reconcile", "move_direct_reconcile" coalesced := row["direct_reconcile_state"] == "observed" && row["read_assisted_reconcile_state"] == "observed" var directView map[string]any @@ -225,9 +262,7 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP if row[route+"_state"] != "observed" || direct["scope"] != scope || direct["clock"] != "monotonic" || direct["accepted_blob_sha"] != blob || direct["additive_with_acknowledgement"] != false || oe != nil || owner == 0 || de != nil || dwall <= 0 || ae != nil { fail("move actual accepted owner/window unavailable") } - if out.A != nil && returned > out.A.WallNS { - fail("move write exceeds actual acknowledgement bounds") - } + if !movePublicationEndpoints(direct, route, dwall, returned, toAcceptance, out.A) { fail("move actual acceptance/write/window endpoints disagree") } @@ -235,17 +270,6 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP fail("coalesced direct work exceeds acknowledgement bounds") } file.OwnerGeneration = owner - original, _ := row["move_request_cause"].(map[string]any) - originalSession, _ := original["session"].(string) - originalKey, _ := original["request_id_key"].(string) - if originalSession == "" || originalKey != "n:"+strconv.FormatInt(wireID, 10) { - fail("original move wire cause unavailable") - } - if session == "" { - session = originalSession - } else if session != originalSession { - fail("move per-file original causes came from different sessions") - } request, _ := direct["request_cause"].(map[string]any) requestSession, _ := request["session"].(string) requestKey, _ := request["request_id_key"].(string) @@ -318,12 +342,52 @@ func checkEditProbeMoveAccounting(statuses map[string]map[string]any, refs editP } out.Files = append(out.Files, file) } + if queuedRows > 0 && (queuedRows != len(refs.Files) || !queuedWaitRequired && queuedWaitMeasured) { + fail("queued move route does not explain shared acknowledgement execution") + } if len(out.Reasons) == 0 { - out.State = "observed_move_windows_checks_pass" + if queuedRows == len(refs.Files) { + out.State = "qualified_queued_ack_write_only" + fail("queued publication: physical P/B and direct/R windows are unavailable; shared ACK and file writes only") + } else { + out.State = "observed_move_windows_checks_pass" + } } return out } +// Applicability belongs to each file, while execution/counts describe the one +// shared RPC. A failed or fast file can share a real wait for its sibling; +// that wait never becomes a per-file publication or background observation. +func moveQueuedAcknowledgementPhase(row map[string]any, name string, value any, present bool, state, applicability, rawCounts any) bool { + background := row["requested_background_state"] + if background != "admitted" && background != "admission_failed" { + return false + } + if name == "publish" { + if applicability != "applicable" || state != "measured" || value == nil { + return false + } + } else if background == "admission_failed" { + if applicability != "not_observed" { + return false + } + } else if applicability != "applicable" && applicability != "not_applicable_fast_ack" { + return false + } + count, _ := rawCounts.(map[string]any) + started, se := strconvEditProbeUint(count["started"]) + completed, ce := strconvEditProbeUint(count["completed"]) + na, ne := strconvEditProbeUint(count["not_applicable"]) + if !present || se != nil || ce != nil || ne != nil || na != 0 { + return false + } + if value == nil { + return applicability != "applicable" && state == "unobserved" && started == 0 && completed == 0 + } + return state == "measured" && started > 0 && completed == started +} + func (r editProbeMoveReferences) validateReferences() error { if r.ReferenceError != "" { return errors.New(r.ReferenceError) diff --git a/bench/load/edit_probe_move_queued_test.go b/bench/load/edit_probe_move_queued_test.go new file mode 100644 index 000000000..4c5f92ade --- /dev/null +++ b/bench/load/edit_probe_move_queued_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func moveQueuedFixture(t *testing.T, mode string) (editProbeMoveReferences, map[string]map[string]any) { + t.Helper() + refs, rows := moveAccountingFixture(t) + for _, row := range rows { + delete(row, "direct_reconcile") + delete(row, "read_assisted_reconcile") + row["direct_reconcile_state"], row["read_assisted_reconcile_state"] = "unavailable_queued_publication", "unavailable_queued_publication" + row["requested_background_state"] = "admitted" + app := map[string]any{"publish": "applicable", "reindex_wait": "applicable"} + row["acknowledgement_phase_applicability"] = app + ack := row["acknowledgement"].(map[string]any) + phases := ack["observed_execution_ms"].(map[string]any) + states := ack["phase_states"].(map[string]any) + counts := ack["counts"].(map[string]any) + for _, name := range []string{"publish", "reindex_wait"} { + phases[name], states[name] = json.Number("10"), "measured" + counts[name] = map[string]any{"started": json.Number("1"), "completed": json.Number("1"), "not_applicable": json.Number("0")} + } + ack["caller_response_ready_ms"] = json.Number("80") + if mode != "admitted_wait" { + ack["caller_response_ready_ms"] = json.Number("70") + phases["reindex_wait"], states["reindex_wait"] = nil, "unobserved" + counts["reindex_wait"] = map[string]any{"started": json.Number("0"), "completed": json.Number("0"), "not_applicable": json.Number("0")} + app["reindex_wait"] = "not_applicable_fast_ack" + } + if mode == "admission_failed" { + row["requested_background_state"] = "admission_failed" + app["reindex_wait"] = "not_observed" + counts["reindex_wait"].(map[string]any)["not_applicable"] = json.Number("0") + } + } + return refs, rows +} + +func TestMoveQueuedConsumerKeepsACKAndWriteQualifiedWithoutPublicationCredit(t *testing.T) { + for _, mode := range []string{"admitted_wait", "admitted_fast", "admission_failed"} { + t.Run(mode, func(t *testing.T) { + refs, rows := moveQueuedFixture(t, mode) + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State != "qualified_queued_ack_write_only" || got.A == nil || len(got.Files) != 2 { + t.Fatalf("known queued route lost its qualified actual ACK/write observations: %+v", got) + } + if len(got.Reasons) == 0 { + t.Fatal("queued publication must retain the missing physical P/B and direct/R qualification") + } + for _, file := range got.Files { + if file.D != nil || file.R != nil || file.WriteWindowNS == nil || !strings.HasPrefix(file.Route, "queued_publication") { + t.Fatalf("queued row borrowed or invented a publication window: %+v", file) + } + } + }) + } +} + +func TestMoveQueuedConsumerRefusesContradictionsAndForeignACKOrCause(t *testing.T) { + for _, fault := range []string{"direct_map", "background_map", "wire_cause", "foreign_ack", "write_window", "publish_phase", "missing_route", "wait_count", "invented_fast_wait", "outside_ack", "split_session"} { + t.Run(fault, func(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + row := rows["source"] + switch fault { + case "direct_map": + row["direct_reconcile"] = map[string]any{"window_ns": json.Number("0")} + case "background_map": + row["requested_background"] = map[string]any{"background_wall_ms": json.Number("0")} + case "wire_cause": + row["move_request_cause"].(map[string]any)["request_id_key"] = "n:18" + case "foreign_ack": + row["acknowledgement"].(map[string]any)["rpc_id"] = "foreign" + case "write_window": + delete(row, "write_timing") + case "publish_phase": + row["acknowledgement"].(map[string]any)["observed_execution_ms"].(map[string]any)["publish"] = nil + case "wait_count": + row["acknowledgement"].(map[string]any)["counts"].(map[string]any)["reindex_wait"].(map[string]any)["completed"] = json.Number("0") + case "invented_fast_wait": + for _, r := range rows { + r["acknowledgement_phase_applicability"].(map[string]any)["reindex_wait"] = "not_applicable_fast_ack" + } + case "outside_ack": + row["write_timing"].(map[string]any)["returned_ns"] = json.Number("100000000") + row["write_timing"].(map[string]any)["window_ns"] = json.Number("99000000") + case "split_session": + row["move_request_cause"].(map[string]any)["session"] = "foreign-session" + case "missing_route": + delete(row, "requested_background_state") + } + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State == "qualified_queued_ack_write_only" || got.State == "observed_move_windows_checks_pass" { + t.Fatalf("invalid queued evidence credited: %s %+v", fault, got) + } + }) + } +} + +func TestMoveQueuedConsumerSharesRealACKWaitWithoutAssigningItToFailedSibling(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + rows["target"]["requested_background_state"] = "admission_failed" + rows["target"]["acknowledgement_phase_applicability"].(map[string]any)["reindex_wait"] = "not_observed" + got := checkEditProbeMoveAccounting(rows, refs, 17) + if got.State != "qualified_queued_ack_write_only" || got.A == nil { + t.Fatalf("shared real ACK wait lost or became per-file publication: %+v", got) + } + for _, file := range got.Files { + if file.D != nil || file.R != nil { + t.Fatal("shared ACK wait borrowed as publication") + } + } +} + +func TestMoveQueuedConsumerCollectorNeverMarksGraphTerminalFromQualifiedACK(t *testing.T) { + refs, rows := moveQueuedFixture(t, "admitted_wait") + socket := startStub(t, func(frame stubFrame) (any, *rpcError) { + args, _ := frame.Params["arguments"].(map[string]any) + options, _ := args["options"].(map[string]any) + for _, file := range refs.Files { + if options["receipt"] == file.Receipt { + return stubContent(rows[file.Role]), nil + } + } + return stubContent(map[string]any{"found": false}), nil + }) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + client, err := openMCP(ctx, socket, "") + if err != nil { + t.Fatal(err) + } + defer client.Close() + sink, err := os.CreateTemp(t.TempDir(), "queued-move-*.jsonl") + if err != nil { + t.Fatal(err) + } + defer sink.Close() + state := &editProbeState{client: client, sink: sink, record: completeMovePublicRecord()} + state.startRaw() + defer state.finishRaw() + run := &editProbeRuntime{options: runOptions{Deadline: time.Second}, p: &privateRun{Repos: []privateRepo{{Prefix: "fixture"}}}, phase: &Phase{Metrics: map[string]Metric{}}, origin: time.Now(), requireAccounting: true} + run.collectMoveEvidence(ctx, state, refs, 17) + if ctx.Err() != nil || state.record.MoveAccounting == nil || state.record.MoveAccounting.State != "qualified_queued_ack_write_only" || state.record.GraphTerminal || state.record.Accounting.P != nil || state.record.Accounting.B != nil { + t.Fatalf("qualified shared ACK became physical publication evidence: %+v %v", state.record, ctx.Err()) + } +} From 2c4f1c58fd0a522feb87ac0ad00f643e9689550f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:18:41 +0100 Subject: [PATCH 103/138] test(mcp): require exact existing move physical status child --- internal/mcp/move_physical_status_test.go | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 internal/mcp/move_physical_status_test.go diff --git a/internal/mcp/move_physical_status_test.go b/internal/mcp/move_physical_status_test.go new file mode 100644 index 000000000..f4fbdde11 --- /dev/null +++ b/internal/mcp/move_physical_status_test.go @@ -0,0 +1,31 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMovePhysicalStatusMatchesExistingGuardedRecord(t *testing.T) { + s, move, ctx, ack, _, target := moveObservationExecutionFixture(t) + ack.finishActual() + ack.finishReply(nil, nil) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + before := s.mutationStatusPayload(physical) + status, handled := s.moveFileObservationPayload(ctx, timing.id) + require.True(t, handled) + require.Equal(t, "observed", status["physical_commit_state"]) + require.Equal(t, before, status["physical_commit"], "render the same owned physical status, including unavailable/null publication evidence") + require.Equal(t, true, status["physical_commit_shared_acknowledgement"]) + require.Equal(t, false, status["physical_commit_additive_with_acknowledgement"]) + require.Equal(t, "move_file_observation", status["receipt_kind"]) + require.NotContains(t, status, "mutation_id") + require.NotContains(t, status, "graph_status_terminal", "the timing row does not become physical authority") + child := status["physical_commit"].(map[string]any) + require.Equal(t, physical.id, child["receipt"]) + child["graph_status"] = "fabricated-client-value" + again, _ := s.moveFileObservationPayload(ctx, timing.id) + require.NotEqual(t, "fabricated-client-value", again["physical_commit"].(map[string]any)["graph_status"]) +} From e2fefdf1d9e7333fbb6d52cf2d2c6a6c739e1737 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:23:25 +0100 Subject: [PATCH 104/138] fix(mcp): expose only exact owned move physical status --- internal/mcp/move_file_observation.go | 88 +++++++++- internal/mcp/move_physical_status_test.go | 198 ++++++++++++++++++++++ 2 files changed, 284 insertions(+), 2 deletions(-) diff --git a/internal/mcp/move_file_observation.go b/internal/mcp/move_file_observation.go index d8fe83a0c..5bb57bcb7 100644 --- a/internal/mcp/move_file_observation.go +++ b/internal/mcp/move_file_observation.go @@ -126,12 +126,88 @@ func linkedMovePhysicalLocked(ledger *mutationCommitLedger, s *Server, record *m } physical.mu.RLock() defer physical.mu.RUnlock() - if ledger.byID[record.id] != record || ledger.byID[physical.id] != physical || record.owner != s || physical.owner != s || record.kind != moveFileObservationCommit || physical.kind != physicalMutationCommit || physical.moveTimingOwner.Value() != record || record.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != record.absPath || physical.disk != mutationDiskCommitted || physical.commitSeq == 0 || physical.commitSeq != record.commitSeq || physical.newSHA == "" || physical.newSHA != record.newSHA || !physical.committedAt.Equal(record.committedAt) || !physical.diskWriteStartedAt.Equal(record.diskWriteStartedAt) { + if ledger.byID[record.id] != record || ledger.byID[physical.id] != physical || record.owner != s || physical.owner != s || record.kind != moveFileObservationCommit || physical.kind != physicalMutationCommit || physical.moveTimingOwner.Value() != record || record.acknowledgement == nil || physical.acknowledgement != record.acknowledgement || physical.absPath != record.absPath || physical.checkoutID != record.checkoutID || physical.checkoutIncarnation != record.checkoutIncarnation || physical.disk != mutationDiskCommitted || physical.commitSeq == 0 || physical.commitSeq != record.commitSeq || physical.newSHA == "" || physical.newSHA != record.newSHA || !physical.committedAt.Equal(record.committedAt) || !physical.diskWriteStartedAt.Equal(record.diskWriteStartedAt) { return nil } return physical } +// A status witness adds no authority: it only authenticates the existing weak +// physical child across rendering, which refreshes and reacquires the ledger. +type movePhysicalStatusWitness struct { + ack *editAcknowledgement + path, blob, checkout, incarnation string + sequence uint64 + committed, writeStarted time.Time +} + +// Caller holds ledger then timing record. Keep the existing newest-owner and +// actual-write fences; never search past an unrelated newer same-path owner. +func movePhysicalStatusLocked(ledger *mutationCommitLedger, s *Server, record *mutationCommitRecord) *mutationCommitRecord { + physical := linkedMovePhysicalLocked(ledger, s, record) + if physical == nil { + return nil + } + firstPath := true + for i := len(ledger.ordered)-1; i >= 0; i-- { + candidate := ledger.ordered[i] + if candidate == physical || (!candidate.isPublicationOwner() && candidate.kind != moveFileObservationCommit) || candidate.absPath != record.absPath { + continue + } + if firstPath { + firstPath = false + if candidate != record { + return nil + } + } + if candidate == record { + continue + } + candidate.mu.RLock() + competing := candidate.disk == mutationDiskCommitted && candidate.commitSeq > record.commitSeq || + candidate.disk == mutationDiskInFlight && !candidate.diskWriteStartedAt.IsZero() + candidate.mu.RUnlock() + if competing { + return nil + } + } + return physical +} + +func (s *Server) movePhysicalStatusChild(record *mutationCommitRecord, checkout, incarnation string, render func(*mutationCommitRecord) map[string]any) map[string]any { + ledger := &s.mutationCommits + ledger.mu.Lock() + record.mu.RLock() + physical := movePhysicalStatusLocked(ledger, s, record) + if physical == nil || !mutationCheckoutScopeMatches(checkout, incarnation, record.checkoutID, record.checkoutIncarnation) { + record.mu.RUnlock() + ledger.mu.Unlock() + return nil + } + witness := movePhysicalStatusWitness{ack: record.acknowledgement, path: record.absPath, blob: record.newSHA, + checkout: record.checkoutID, incarnation: record.checkoutIncarnation, sequence: record.commitSeq, + committed: record.committedAt, writeStarted: record.diskWriteStartedAt} + record.mu.RUnlock() + ledger.mu.Unlock() + + // This refresh may itself take ledger/record locks. No timing/ledger lock + // crosses it, and no P/B/graph fact is borrowed from the shared RPC clock. + child := render(physical) + + ledger.mu.Lock() + defer ledger.mu.Unlock() + record.mu.RLock() + defer record.mu.RUnlock() + if movePhysicalStatusLocked(ledger, s, record) != physical || record.acknowledgement != witness.ack || + record.absPath != witness.path || record.newSHA != witness.blob || record.commitSeq != witness.sequence || + record.checkoutID != witness.checkout || record.checkoutIncarnation != witness.incarnation || + !record.committedAt.Equal(witness.committed) || !record.diskWriteStartedAt.Equal(witness.writeStarted) || + !mutationCheckoutScopeMatches(checkout, incarnation, record.checkoutID, record.checkoutIncarnation) { + return nil + } + return child +} + func (o *moveObservation) finishWrites(success bool) { if o == nil { return @@ -467,7 +543,7 @@ func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map if !strings.HasPrefix(id, "move-file-") { return nil, false } - payload := map[string]any{"found": false, "receipt": id, "receipt_kind": "move_file_observation", "guidance": "observation only; source_id/target_id/touched and real write outcomes remain the move authority"} + payload := map[string]any{"found": false, "receipt": id, "receipt_kind": "move_file_observation", "guidance": "observation only; source_id/target_id/touched and real write outcomes remain the move authority", "physical_commit_state": "unavailable"} checkout, incarnation := mutationCheckoutScope(ctx) var ack *editAcknowledgement var direct, readAssisted *moveDirectObservation @@ -534,6 +610,14 @@ func (s *Server) moveFileObservationPayload(ctx context.Context, id string) (map snapshot = © } payload["acknowledgement"] = buildAcknowledgementPayload(snapshot, "unavailable_ledger_entry_or_scope") + if payload["found"] == true && record != nil { + if child := s.movePhysicalStatusChild(record, checkout, incarnation, s.mutationStatusPayload); child != nil { + payload["physical_commit"] = child + payload["physical_commit_state"] = "observed" + payload["physical_commit_shared_acknowledgement"] = true + payload["physical_commit_additive_with_acknowledgement"] = false + } + } return payload, true } diff --git a/internal/mcp/move_physical_status_test.go b/internal/mcp/move_physical_status_test.go index f4fbdde11..40ae59155 100644 --- a/internal/mcp/move_physical_status_test.go +++ b/internal/mcp/move_physical_status_test.go @@ -2,6 +2,18 @@ package mcp import ( "testing" + "os" + "time" + "weak" + "context" + "path/filepath" + "sync" + "sync/atomic" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "go.uber.org/zap" "github.com/stretchr/testify/require" ) @@ -29,3 +41,189 @@ func TestMovePhysicalStatusMatchesExistingGuardedRecord(t *testing.T) { again, _ := s.moveFileObservationPayload(ctx, timing.id) require.NotEqual(t, "fabricated-client-value", again["physical_commit"].(map[string]any)["graph_status"]) } + +func TestMovePhysicalStatusRejectsBrokenExactOwners(t *testing.T) { + for _, mode := range []string{"ack", "server", "path", "blob", "sequence", "checkout", "incarnation", "evicted", "replaced", "forward-link", "reverse-link", "legacy-in-flight"} { + t.Run(mode, func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + physical := timing.moveFile.physical.Value() + require.NotNil(t, physical) + ledger := &s.mutationCommits + ledger.mu.Lock() + timing.mu.Lock() + physical.mu.Lock() + switch mode { + case "ack": physical.acknowledgement = &editAcknowledgement{} + case "server": physical.owner = &Server{} + case "path": physical.absPath = target+".foreign" + case "blob": physical.newSHA = "foreign" + case "sequence": physical.commitSeq++ + case "checkout": physical.checkoutID = "foreign" + case "incarnation": physical.checkoutIncarnation = "foreign" + case "evicted": delete(ledger.byID, physical.id) + case "replaced": ledger.byID[physical.id] = &mutationCommitRecord{id:physical.id} + case "forward-link": timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "reverse-link": physical.moveTimingOwner = weak.Pointer[mutationCommitRecord]{} + case "legacy-in-flight": + // Model the legacy schedule explicitly. Real held-path admission + // now prevents this old admitted writer from overtaking a move. + legacy := &mutationCommitRecord{owner:s, id:"legacy-in-flight", absPath:target, disk:mutationDiskInFlight, + startedAt:timing.startedAt.Add(-time.Second), diskWriteStartedAt:timing.committedAt.Add(-time.Nanosecond)} + ledger.byID[legacy.id] = legacy + ledger.ordered = append([]*mutationCommitRecord{legacy}, ledger.ordered...) + } + physical.mu.Unlock() + timing.mu.Unlock() + ledger.mu.Unlock() + status, _ := s.moveFileObservationPayload(ctx, timing.id) + require.Equal(t, true, status["found"]) + require.Equal(t, "unavailable", status["physical_commit_state"]) + require.NotContains(t, status, "physical_commit") + }) + } +} + +func TestMovePhysicalStatusDoesNotSearchPastAnotherWriter(t *testing.T) { + for _, noWrite := range []bool{false,true} { + t.Run(map[bool]string{false:"same-bytes-new-ack",true:"cancelled-no-write"}[noWrite], func(t *testing.T) { + s, move, ctx, _, _, target := moveObservationExecutionFixture(t) + original, err := os.ReadFile(target) + require.NoError(t, err) + writeCtx, ack, _ := s.withEditAcknowledgement(t.Context()) + defer ack.finishActual() + writeCtx, cancel := context.WithCancel(writeCtx) + defer cancel() + writeCtx, unlock, err := acquireMutationPath(writeCtx, target) + require.NoError(t, err) + defer unlock() + if noWrite { cancel() } + later, err := s.commitFileMutation(writeCtx,"write_file","new-owner","","target.go",target,original,0600) + if noWrite { require.ErrorIs(t, err, errMutationNotApplied) } else { require.NoError(t, err) } + require.NotNil(t, later) + status, _ := s.moveFileObservationPayload(ctx, move.records[target].id) + require.Equal(t, "unavailable", status["physical_commit_state"]) + require.NotContains(t, status,"physical_commit") + after, err := os.ReadFile(target) + require.NoError(t,err) + require.Equal(t,original,after) + }) + } +} + +func TestMovePhysicalStatusRevalidatesAfterUnlockedRendering(t *testing.T) { + for _, mode := range []string{"evict", "relink", "same-scalars-new-owner"} { + t.Run(mode, func(t *testing.T) { + s, move, _, _, _, target := moveObservationExecutionFixture(t) + timing := move.records[target] + child := s.movePhysicalStatusChild(timing,"","",func(physical *mutationCommitRecord) map[string]any { + actual := s.mutationStatusPayload(physical) // proves ledger/timing locks were released + ledger := &s.mutationCommits + ledger.mu.Lock() + defer ledger.mu.Unlock() + timing.mu.Lock() + defer timing.mu.Unlock() + switch mode { + case "evict": delete(ledger.byID,physical.id) + case "relink": timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "same-scalars-new-owner": timing.acknowledgement = &editAcknowledgement{} + } + return actual + }) + require.Nil(t,child,"an earlier valid scalar snapshot cannot survive lost membership or ownership") + }) + } +} + +type movePhysicalStatusExtractor struct { + parser.Extractor + active atomic.Bool + entered chan struct{} + release chan struct{} +} +func (e *movePhysicalStatusExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult,error) { + if e.active.Load() { + select { case e.entered <- struct{}{}: default: } + <-e.release + } + return e.Extractor.Extract(path,raw) +} + +func TestMovePhysicalStatusTracksRealQueuedPublicationWithoutReparse(t *testing.T) { + extractor := &movePhysicalStatusExtractor{Extractor:languages.NewGoExtractor(),entered:make(chan struct{},16),release:make(chan struct{})} + registry := parser.NewRegistry() + registry.Register(extractor) + s,root := setupTestServerWithRegistry(t,registry) + s.editAckMode = config.EditAckLocal + source,target := filepath.Join(root,"source.go"),filepath.Join(root,"target.go") + require.NoError(t,os.WriteFile(source,[]byte("package main\nfunc Before() {}\n"),0600)) + require.NoError(t,os.WriteFile(target,[]byte("package main\nfunc OldTarget() {}\n"),0600)) + _,err := s.indexer.IncrementalReindexPaths(root,[]string{source,target}) + require.NoError(t,err) + watcher,err := indexer.NewWatcher(s.indexer,config.WatchConfig{Enabled:true,DebounceMs:5},zap.NewNop()) + require.NoError(t,err) + var release sync.Once + t.Cleanup(func(){ release.Do(func(){close(extractor.release)}); stopErr := watcher.Stop(); s.DrainBackground(); s.indexer.Close(); require.NoError(t,stopErr) }) + s.SetWatcher(watcher) + ctx,ack,_ := s.withEditAcknowledgement(canonicalTimingContext()) + ctx,unlock,err := acquireMutationPaths(ctx,[]string{source,target}) + require.NoError(t,err) + defer unlock() + move := s.beginMoveObservation(ctx,"source.go::Before","target.go::Before",source,target,"source.go","target.go",true) + require.NotNil(t,move) + physical := make(map[string]*mutationCommitRecord) + physical[target],err = move.write(ctx,s,target,[]byte("package main\nfunc Moved() {}\n")) + require.NoError(t,err) + physical[source],err = move.write(ctx,s,source,[]byte("package main\nfunc After() {}\n")) + require.NoError(t,err) + move.finishWrites(true) + extractor.active.Store(true) // only the REAL background indexer is held + var admissions []sourceMutationAdmission + for _,path := range []string{source,target} { + admission := s.admitSourceMutations(ctx,path) + require.NotNil(t,admission.receipt) + move.observeAdmission(path,&admission) + admissions = append(admissions,admission) + } + unlock() + outcomes := s.awaitSourceMutationFrontier(ctx,admissions) + for i,path := range []string{source,target} { require.True(t,outcomes[i].Pending); physical[path].recordGraph(outcomes[i]) } + ack.finishActual() + ack.finishReply(nil,nil) + select { case <-extractor.entered: case <-time.After(3*time.Second): t.Fatal("real watcher did not enter extraction") } + for _,path := range []string{source,target} { + status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) + require.Equal(t,"observed",status["physical_commit_state"]) + require.Equal(t,"admitted",status["requested_background_state"]) + require.Equal(t,"unavailable_queued_publication",status["direct_reconcile_state"]) + child := status["physical_commit"].(map[string]any) + require.Equal(t,mutationGraphPending,child["graph_status"]) + require.Equal(t,false,child["graph_status_terminal"]) + require.Equal(t,s.mutationStatusPayload(physical[path]),child) + require.NotContains(t,status,"graph_status_terminal") + } + release.Do(func(){close(extractor.release)}) + for _,path := range []string{source,target} { + require.Eventually(t,func()bool { + status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) + child,ok := status["physical_commit"].(map[string]any) + return ok && child["graph_status"]==mutationGraphFresh && child["graph_status_terminal"]==true + },5*time.Second,time.Millisecond) + status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) + child := status["physical_commit"].(map[string]any) + require.Equal(t,s.mutationStatusPayload(physical[path]),child) + phases,ok := child["publication_phases"].(indexer.PublicationPhaseSnapshot) + require.True(t,ok) + require.True(t,phases.Terminal) + require.Equal(t,indexer.PublicationSourceMCPEdit,phases.Source) + } + parses := parser.ParseCount() + beforeSource,err := os.ReadFile(source);require.NoError(t,err) + beforeTarget,err := os.ReadFile(target);require.NoError(t,err) + for range 3 { for _,path := range []string{source,target} { s.moveFileObservationPayload(ctx,move.records[path].id) } } + require.Equal(t,parses,parser.ParseCount(),"status cannot trigger a second parse/reconcile") + afterSource,err := os.ReadFile(source);require.NoError(t,err) + afterTarget,err := os.ReadFile(target);require.NoError(t,err) + require.Equal(t,beforeSource,afterSource) + require.Equal(t,beforeTarget,afterTarget) +} From cfa8802cf30565391fc53ef49c48cfbb54a2c104 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:28:51 +0100 Subject: [PATCH 105/138] test(mcp): retain shared move ACK and nested-only publication scope --- internal/mcp/move_physical_status_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/mcp/move_physical_status_test.go b/internal/mcp/move_physical_status_test.go index 40ae59155..c81aa7b22 100644 --- a/internal/mcp/move_physical_status_test.go +++ b/internal/mcp/move_physical_status_test.go @@ -37,6 +37,8 @@ func TestMovePhysicalStatusMatchesExistingGuardedRecord(t *testing.T) { require.NotContains(t, status, "graph_status_terminal", "the timing row does not become physical authority") child := status["physical_commit"].(map[string]any) require.Equal(t, physical.id, child["receipt"]) + require.NotContains(t, status, "publication_phases", "physical publication is nested, never promoted to timing-row authority") + require.Equal(t, status["acknowledgement"].(acknowledgementPayload).RPCID, child["acknowledgement"].(acknowledgementPayload).RPCID, "the child shares the actual RPC ACK; it is not another additive interval") child["graph_status"] = "fabricated-client-value" again, _ := s.moveFileObservationPayload(ctx, timing.id) require.NotEqual(t, "fabricated-client-value", again["physical_commit"].(map[string]any)["graph_status"]) From 36c4e06787d6696050a830af0b0097fa01f6e781 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:40:35 +0100 Subject: [PATCH 106/138] test(mcp): start real watcher lifecycle for point publication control --- internal/mcp/move_file_observation.go | 8 +- internal/mcp/move_physical_status_test.go | 241 +++++++++++++--------- 2 files changed, 149 insertions(+), 100 deletions(-) diff --git a/internal/mcp/move_file_observation.go b/internal/mcp/move_file_observation.go index 5bb57bcb7..0edc90a22 100644 --- a/internal/mcp/move_file_observation.go +++ b/internal/mcp/move_file_observation.go @@ -135,10 +135,10 @@ func linkedMovePhysicalLocked(ledger *mutationCommitLedger, s *Server, record *m // A status witness adds no authority: it only authenticates the existing weak // physical child across rendering, which refreshes and reacquires the ledger. type movePhysicalStatusWitness struct { - ack *editAcknowledgement + ack *editAcknowledgement path, blob, checkout, incarnation string - sequence uint64 - committed, writeStarted time.Time + sequence uint64 + committed, writeStarted time.Time } // Caller holds ledger then timing record. Keep the existing newest-owner and @@ -149,7 +149,7 @@ func movePhysicalStatusLocked(ledger *mutationCommitLedger, s *Server, record *m return nil } firstPath := true - for i := len(ledger.ordered)-1; i >= 0; i-- { + for i := len(ledger.ordered) - 1; i >= 0; i-- { candidate := ledger.ordered[i] if candidate == physical || (!candidate.isPublicationOwner() && candidate.kind != moveFileObservationCommit) || candidate.absPath != record.absPath { continue diff --git a/internal/mcp/move_physical_status_test.go b/internal/mcp/move_physical_status_test.go index c81aa7b22..0a3108216 100644 --- a/internal/mcp/move_physical_status_test.go +++ b/internal/mcp/move_physical_status_test.go @@ -1,19 +1,19 @@ package mcp import ( - "testing" - "os" - "time" - "weak" "context" - "path/filepath" - "sync" - "sync/atomic" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/parser/languages" "go.uber.org/zap" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + "weak" "github.com/stretchr/testify/require" ) @@ -56,22 +56,33 @@ func TestMovePhysicalStatusRejectsBrokenExactOwners(t *testing.T) { timing.mu.Lock() physical.mu.Lock() switch mode { - case "ack": physical.acknowledgement = &editAcknowledgement{} - case "server": physical.owner = &Server{} - case "path": physical.absPath = target+".foreign" - case "blob": physical.newSHA = "foreign" - case "sequence": physical.commitSeq++ - case "checkout": physical.checkoutID = "foreign" - case "incarnation": physical.checkoutIncarnation = "foreign" - case "evicted": delete(ledger.byID, physical.id) - case "replaced": ledger.byID[physical.id] = &mutationCommitRecord{id:physical.id} - case "forward-link": timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} - case "reverse-link": physical.moveTimingOwner = weak.Pointer[mutationCommitRecord]{} + case "ack": + physical.acknowledgement = &editAcknowledgement{} + case "server": + physical.owner = &Server{} + case "path": + physical.absPath = target + ".foreign" + case "blob": + physical.newSHA = "foreign" + case "sequence": + physical.commitSeq++ + case "checkout": + physical.checkoutID = "foreign" + case "incarnation": + physical.checkoutIncarnation = "foreign" + case "evicted": + delete(ledger.byID, physical.id) + case "replaced": + ledger.byID[physical.id] = &mutationCommitRecord{id: physical.id} + case "forward-link": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "reverse-link": + physical.moveTimingOwner = weak.Pointer[mutationCommitRecord]{} case "legacy-in-flight": // Model the legacy schedule explicitly. Real held-path admission // now prevents this old admitted writer from overtaking a move. - legacy := &mutationCommitRecord{owner:s, id:"legacy-in-flight", absPath:target, disk:mutationDiskInFlight, - startedAt:timing.startedAt.Add(-time.Second), diskWriteStartedAt:timing.committedAt.Add(-time.Nanosecond)} + legacy := &mutationCommitRecord{owner: s, id: "legacy-in-flight", absPath: target, disk: mutationDiskInFlight, + startedAt: timing.startedAt.Add(-time.Second), diskWriteStartedAt: timing.committedAt.Add(-time.Nanosecond)} ledger.byID[legacy.id] = legacy ledger.ordered = append([]*mutationCommitRecord{legacy}, ledger.ordered...) } @@ -87,8 +98,8 @@ func TestMovePhysicalStatusRejectsBrokenExactOwners(t *testing.T) { } func TestMovePhysicalStatusDoesNotSearchPastAnotherWriter(t *testing.T) { - for _, noWrite := range []bool{false,true} { - t.Run(map[bool]string{false:"same-bytes-new-ack",true:"cancelled-no-write"}[noWrite], func(t *testing.T) { + for _, noWrite := range []bool{false, true} { + t.Run(map[bool]string{false: "same-bytes-new-ack", true: "cancelled-no-write"}[noWrite], func(t *testing.T) { s, move, ctx, _, _, target := moveObservationExecutionFixture(t) original, err := os.ReadFile(target) require.NoError(t, err) @@ -99,16 +110,22 @@ func TestMovePhysicalStatusDoesNotSearchPastAnotherWriter(t *testing.T) { writeCtx, unlock, err := acquireMutationPath(writeCtx, target) require.NoError(t, err) defer unlock() - if noWrite { cancel() } - later, err := s.commitFileMutation(writeCtx,"write_file","new-owner","","target.go",target,original,0600) - if noWrite { require.ErrorIs(t, err, errMutationNotApplied) } else { require.NoError(t, err) } + if noWrite { + cancel() + } + later, err := s.commitFileMutation(writeCtx, "write_file", "new-owner", "", "target.go", target, original, 0600) + if noWrite { + require.ErrorIs(t, err, errMutationNotApplied) + } else { + require.NoError(t, err) + } require.NotNil(t, later) status, _ := s.moveFileObservationPayload(ctx, move.records[target].id) require.Equal(t, "unavailable", status["physical_commit_state"]) - require.NotContains(t, status,"physical_commit") + require.NotContains(t, status, "physical_commit") after, err := os.ReadFile(target) - require.NoError(t,err) - require.Equal(t,original,after) + require.NoError(t, err) + require.Equal(t, original, after) }) } } @@ -118,7 +135,7 @@ func TestMovePhysicalStatusRevalidatesAfterUnlockedRendering(t *testing.T) { t.Run(mode, func(t *testing.T) { s, move, _, _, _, target := moveObservationExecutionFixture(t) timing := move.records[target] - child := s.movePhysicalStatusChild(timing,"","",func(physical *mutationCommitRecord) map[string]any { + child := s.movePhysicalStatusChild(timing, "", "", func(physical *mutationCommitRecord) map[string]any { actual := s.mutationStatusPayload(physical) // proves ledger/timing locks were released ledger := &s.mutationCommits ledger.mu.Lock() @@ -126,106 +143,138 @@ func TestMovePhysicalStatusRevalidatesAfterUnlockedRendering(t *testing.T) { timing.mu.Lock() defer timing.mu.Unlock() switch mode { - case "evict": delete(ledger.byID,physical.id) - case "relink": timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} - case "same-scalars-new-owner": timing.acknowledgement = &editAcknowledgement{} + case "evict": + delete(ledger.byID, physical.id) + case "relink": + timing.moveFile.physical = weak.Pointer[mutationCommitRecord]{} + case "same-scalars-new-owner": + timing.acknowledgement = &editAcknowledgement{} } return actual }) - require.Nil(t,child,"an earlier valid scalar snapshot cannot survive lost membership or ownership") + require.Nil(t, child, "an earlier valid scalar snapshot cannot survive lost membership or ownership") }) } } type movePhysicalStatusExtractor struct { parser.Extractor - active atomic.Bool + active atomic.Bool entered chan struct{} release chan struct{} } -func (e *movePhysicalStatusExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult,error) { + +func (e *movePhysicalStatusExtractor) Extract(path string, raw []byte) (*parser.ExtractionResult, error) { if e.active.Load() { - select { case e.entered <- struct{}{}: default: } + select { + case e.entered <- struct{}{}: + default: + } <-e.release } - return e.Extractor.Extract(path,raw) + return e.Extractor.Extract(path, raw) } -func TestMovePhysicalStatusTracksRealQueuedPublicationWithoutReparse(t *testing.T) { - extractor := &movePhysicalStatusExtractor{Extractor:languages.NewGoExtractor(),entered:make(chan struct{},16),release:make(chan struct{})} +func TestMovePhysicalStatusTracksRealQueuedPointPublicationWithoutReparse(t *testing.T) { + extractor := &movePhysicalStatusExtractor{Extractor: languages.NewGoExtractor(), entered: make(chan struct{}, 16), release: make(chan struct{})} registry := parser.NewRegistry() registry.Register(extractor) - s,root := setupTestServerWithRegistry(t,registry) + s, root := setupTestServerWithRegistry(t, registry) s.editAckMode = config.EditAckLocal - source,target := filepath.Join(root,"source.go"),filepath.Join(root,"target.go") - require.NoError(t,os.WriteFile(source,[]byte("package main\nfunc Before() {}\n"),0600)) - require.NoError(t,os.WriteFile(target,[]byte("package main\nfunc OldTarget() {}\n"),0600)) - _,err := s.indexer.IncrementalReindexPaths(root,[]string{source,target}) - require.NoError(t,err) - watcher,err := indexer.NewWatcher(s.indexer,config.WatchConfig{Enabled:true,DebounceMs:5},zap.NewNop()) - require.NoError(t,err) + source, target := filepath.Join(root, "source.go"), filepath.Join(root, "target.go") + require.NoError(t, os.WriteFile(source, []byte("package main\nfunc Before() {}\n"), 0600)) + require.NoError(t, os.WriteFile(target, []byte("package main\nfunc OldTarget() {}\n"), 0600)) + _, err := s.indexer.IncrementalReindexPaths(root, []string{source, target}) + require.NoError(t, err) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(t, err) + // Start and stop the real native frontend, but watch an idle owned + // directory so filesystem events cannot race the explicit point-ticket + // admission this control measures. The real indexer still owns root. + require.NoError(t, watcher.Start([]string{t.TempDir()})) var release sync.Once - t.Cleanup(func(){ release.Do(func(){close(extractor.release)}); stopErr := watcher.Stop(); s.DrainBackground(); s.indexer.Close(); require.NoError(t,stopErr) }) + t.Cleanup(func() { + release.Do(func() { close(extractor.release) }) + stopErr := watcher.Stop() + s.DrainBackground() + s.indexer.Close() + require.NoError(t, stopErr) + }) s.SetWatcher(watcher) - ctx,ack,_ := s.withEditAcknowledgement(canonicalTimingContext()) - ctx,unlock,err := acquireMutationPaths(ctx,[]string{source,target}) - require.NoError(t,err) + ctx, ack, _ := s.withEditAcknowledgement(canonicalTimingContext()) + ctx, unlock, err := acquireMutationPaths(ctx, []string{source, target}) + require.NoError(t, err) defer unlock() - move := s.beginMoveObservation(ctx,"source.go::Before","target.go::Before",source,target,"source.go","target.go",true) - require.NotNil(t,move) + move := s.beginMoveObservation(ctx, "source.go::Before", "target.go::Before", source, target, "source.go", "target.go", true) + require.NotNil(t, move) physical := make(map[string]*mutationCommitRecord) - physical[target],err = move.write(ctx,s,target,[]byte("package main\nfunc Moved() {}\n")) - require.NoError(t,err) - physical[source],err = move.write(ctx,s,source,[]byte("package main\nfunc After() {}\n")) - require.NoError(t,err) + physical[target], err = move.write(ctx, s, target, []byte("package main\nfunc Moved() {}\n")) + require.NoError(t, err) + physical[source], err = move.write(ctx, s, source, []byte("package main\nfunc After() {}\n")) + require.NoError(t, err) move.finishWrites(true) extractor.active.Store(true) // only the REAL background indexer is held var admissions []sourceMutationAdmission - for _,path := range []string{source,target} { - admission := s.admitSourceMutations(ctx,path) - require.NotNil(t,admission.receipt) - move.observeAdmission(path,&admission) - admissions = append(admissions,admission) + for _, path := range []string{source, target} { + admission := s.admitSourceMutations(ctx, path) + require.NotNil(t, admission.receipt) + move.observeAdmission(path, &admission) + admissions = append(admissions, admission) } unlock() - outcomes := s.awaitSourceMutationFrontier(ctx,admissions) - for i,path := range []string{source,target} { require.True(t,outcomes[i].Pending); physical[path].recordGraph(outcomes[i]) } + outcomes := s.awaitSourceMutationFrontier(ctx, admissions) + for i, path := range []string{source, target} { + require.True(t, outcomes[i].Pending) + physical[path].recordGraph(outcomes[i]) + } ack.finishActual() - ack.finishReply(nil,nil) - select { case <-extractor.entered: case <-time.After(3*time.Second): t.Fatal("real watcher did not enter extraction") } - for _,path := range []string{source,target} { - status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) - require.Equal(t,"observed",status["physical_commit_state"]) - require.Equal(t,"admitted",status["requested_background_state"]) - require.Equal(t,"unavailable_queued_publication",status["direct_reconcile_state"]) + ack.finishReply(nil, nil) + select { + case <-extractor.entered: + case <-time.After(3 * time.Second): + t.Fatal("real watcher did not enter extraction") + } + for _, path := range []string{source, target} { + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) + require.Equal(t, "observed", status["physical_commit_state"]) + require.Equal(t, "admitted", status["requested_background_state"]) + require.Equal(t, "unavailable_queued_publication", status["direct_reconcile_state"]) child := status["physical_commit"].(map[string]any) - require.Equal(t,mutationGraphPending,child["graph_status"]) - require.Equal(t,false,child["graph_status_terminal"]) - require.Equal(t,s.mutationStatusPayload(physical[path]),child) - require.NotContains(t,status,"graph_status_terminal") + require.Equal(t, mutationGraphPending, child["graph_status"]) + require.Equal(t, false, child["graph_status_terminal"]) + require.Equal(t, s.mutationStatusPayload(physical[path]), child) + require.NotContains(t, status, "graph_status_terminal") } - release.Do(func(){close(extractor.release)}) - for _,path := range []string{source,target} { - require.Eventually(t,func()bool { - status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) - child,ok := status["physical_commit"].(map[string]any) - return ok && child["graph_status"]==mutationGraphFresh && child["graph_status_terminal"]==true - },5*time.Second,time.Millisecond) - status,_ := s.moveFileObservationPayload(ctx,move.records[path].id) + release.Do(func() { close(extractor.release) }) + for _, path := range []string{source, target} { + require.Eventually(t, func() bool { + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) + child, ok := status["physical_commit"].(map[string]any) + return ok && child["graph_status"] == mutationGraphFresh && child["graph_status_terminal"] == true + }, 5*time.Second, time.Millisecond) + status, _ := s.moveFileObservationPayload(ctx, move.records[path].id) child := status["physical_commit"].(map[string]any) - require.Equal(t,s.mutationStatusPayload(physical[path]),child) - phases,ok := child["publication_phases"].(indexer.PublicationPhaseSnapshot) - require.True(t,ok) - require.True(t,phases.Terminal) - require.Equal(t,indexer.PublicationSourceMCPEdit,phases.Source) + require.Equal(t, s.mutationStatusPayload(physical[path]), child) + phases, ok := child["publication_phases"].(indexer.PublicationPhaseSnapshot) + require.True(t, ok) + require.True(t, phases.Terminal) + require.Equal(t, indexer.PublicationSourceMCPEdit, phases.Source) } parses := parser.ParseCount() - beforeSource,err := os.ReadFile(source);require.NoError(t,err) - beforeTarget,err := os.ReadFile(target);require.NoError(t,err) - for range 3 { for _,path := range []string{source,target} { s.moveFileObservationPayload(ctx,move.records[path].id) } } - require.Equal(t,parses,parser.ParseCount(),"status cannot trigger a second parse/reconcile") - afterSource,err := os.ReadFile(source);require.NoError(t,err) - afterTarget,err := os.ReadFile(target);require.NoError(t,err) - require.Equal(t,beforeSource,afterSource) - require.Equal(t,beforeTarget,afterTarget) + beforeSource, err := os.ReadFile(source) + require.NoError(t, err) + beforeTarget, err := os.ReadFile(target) + require.NoError(t, err) + for range 3 { + for _, path := range []string{source, target} { + s.moveFileObservationPayload(ctx, move.records[path].id) + } + } + require.Equal(t, parses, parser.ParseCount(), "status cannot trigger a second parse/reconcile") + afterSource, err := os.ReadFile(source) + require.NoError(t, err) + afterTarget, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, beforeSource, afterSource) + require.Equal(t, beforeTarget, afterTarget) } From cd320ad8f185b15fac84e3efef9162ea25c9bb8e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 20:21:34 +0100 Subject: [PATCH 107/138] indexer: split admitted extraction out of ExtractSource extractSourceWith parses bytes already admitted under a raw-source lease, with the native-tree admission, crash isolation and index safeguards of a normal parse. ExtractSource still admits against the shared budget. A caller that must not queue behind bulk indexing can admit against a budget of its own and parse the same way. --- internal/indexer/extract_source.go | 32 +++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/indexer/extract_source.go b/internal/indexer/extract_source.go index 506782209..aa38180aa 100644 --- a/internal/indexer/extract_source.go +++ b/internal/indexer/extract_source.go @@ -62,12 +62,6 @@ func (idx *Indexer) ExtractSource(ctx context.Context, filePath string, src []by return nil, fmt.Errorf("prepared source exceeds bounded extraction limit (%d bytes)", limit) } - var pool *crashpool.Pool - var quarantine *crashpool.Quarantine - if idx.crashIsolationEnabled() { - pool, quarantine = idx.sharedParsePool() - } - nativeAdmission := newNativeParseExtractionAdmission(0, nil, idx.nativeParseAdmission.Load()) rawLease, err := acquireParseAdmission( ctx, int64(len(prepared)), 0, nil, idx.parseAdmission.Load(), ) @@ -75,10 +69,34 @@ func (idx *Indexer) ExtractSource(ctx context.Context, filePath string, src []by return nil, fmt.Errorf("source admission: %w", err) } defer rawLease.Release() + nativeAdmission := newNativeParseExtractionAdmission(0, nil, idx.nativeParseAdmission.Load()) + return idx.extractSourceWith( + ctx, rawLease, nativeAdmission, + path, filePath, language, extractor, prepared, + ) +} +// extractSourceWith parses prepared caller-owned bytes admitted under +// rawLease, with the native-tree admission, crash isolation and index +// safeguards of a normal parse. Interactive callers admit the bytes against +// a reserved budget so a full shared budget cannot hold them behind bulk +// indexing. +func (idx *Indexer) extractSourceWith( + ctx context.Context, + rawLease *parseAdmissionLease, + nativeAdmission *nativeParseExtractionAdmission, + path, relPath, language string, + extractor parser.Extractor, + src []byte, +) (*parser.ExtractionResult, error) { + var pool *crashpool.Pool + var quarantine *crashpool.Quarantine + if idx.crashIsolationEnabled() { + pool, quarantine = idx.sharedParsePool() + } result, skipped, err := idx.extractFileCtxWithRawLease( ctx, nativeAdmission, rawLease, pool, quarantine, - path, filePath, language, extractor, prepared, + path, relPath, language, extractor, src, ) if err != nil { if result != nil { From 08911bf969fc53b31ab8f96edba46b27f6824e85 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 19:20:59 +0100 Subject: [PATCH 108/138] pathkey: add MutationKey, one identity per source file A tool request names a file in the caller's spelling (/tmp/repo/a.go, a symlinked workspace, a case variant on a case-insensitive volume), while the watcher reports the root's spelling. A lexical key gives each spelling its own entry, so two edits of one file can interleave and one of them is lost, and state recorded under one spelling is missed under the other. MutationKey resolves symlinks in the parent directory, keeping a missing suffix as written, then folds the result like IdentityKey. The leaf is never followed: writing a symlinked file replaces the link, so the link and its target are distinct files. --- internal/pathkey/mutation_key.go | 28 +++++++++++ internal/pathkey/mutation_key_test.go | 67 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/pathkey/mutation_key.go create mode 100644 internal/pathkey/mutation_key_test.go diff --git a/internal/pathkey/mutation_key.go b/internal/pathkey/mutation_key.go new file mode 100644 index 000000000..a26e29540 --- /dev/null +++ b/internal/pathkey/mutation_key.go @@ -0,0 +1,28 @@ +package pathkey + +import "path/filepath" + +// MutationKey returns the one identity under which every writer and reader of +// a source file agrees on that file: the mutation path lock, the pending +// projection of the bytes a tool just wrote, and the watcher events that +// follow the write. +// +// A tool request names a file in the caller's spelling (/tmp/repo/a.go, a +// symlinked workspace, a case variant on a case-insensitive volume) while the +// watcher reports the root's spelling (/private/tmp/repo/a.go). A lexical key +// gives each spelling its own lock, so two edits of one file can interleave +// and one of them is lost. MutationKey resolves symlinks in the parent +// directory, keeping a missing suffix as written, then folds the result like +// IdentityKey. The leaf itself is never followed: writing a symlinked file +// replaces the link, so the link and its target are distinct files. +func MutationKey(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return IdentityKey(path) + } + dir, leaf := filepath.Split(filepath.Clean(abs)) + if leaf == "" { + return IdentityKey(CanonicalPath(abs)) + } + return IdentityKey(filepath.Join(CanonicalPath(dir), leaf)) +} diff --git a/internal/pathkey/mutation_key_test.go b/internal/pathkey/mutation_key_test.go new file mode 100644 index 000000000..b99ae6dae --- /dev/null +++ b/internal/pathkey/mutation_key_test.go @@ -0,0 +1,67 @@ +package pathkey + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestMutationKeyJoinsAliasSpellings(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "a.go"), []byte("package a\n"), 0o644); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(base, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + same := []struct { + name string + a, b string + }{ + {name: "symlinked directory", a: filepath.Join(realDir, "a.go"), b: filepath.Join(aliasDir, "a.go")}, + {name: "file not created yet", a: filepath.Join(realDir, "new.go"), b: filepath.Join(aliasDir, "new.go")}, + {name: "dot segments", a: filepath.Join(realDir, "a.go"), b: realDir + string(filepath.Separator) + "." + string(filepath.Separator) + "a.go"}, + } + if runtime.GOOS == "darwin" { + same = append(same, struct { + name string + a, b string + }{name: "tmp alias", a: "/tmp/gortex-mutation-key/a.go", b: "/private/tmp/gortex-mutation-key/a.go"}) + } + for _, tc := range same { + t.Run(tc.name, func(t *testing.T) { + if ka, kb := MutationKey(tc.a), MutationKey(tc.b); ka != kb { + t.Fatalf("MutationKey(%q) = %q, MutationKey(%q) = %q; want one key", tc.a, ka, tc.b, kb) + } + }) + } + + t.Run("case variant on a case-insensitive volume", func(t *testing.T) { + prev := CaseInsensitivePaths + CaseInsensitivePaths = true + t.Cleanup(func() { CaseInsensitivePaths = prev }) + a := filepath.Join(realDir, "a.go") + for _, b := range []string{filepath.Join(realDir, "A.go"), filepath.Join(base, "REAL", "a.go"), filepath.Join(aliasDir, "A.GO")} { + if ka, kb := MutationKey(a), MutationKey(b); ka != kb { + t.Fatalf("MutationKey(%q) = %q, MutationKey(%q) = %q; want one key", a, ka, b, kb) + } + } + }) + + t.Run("leaf symlink is its own file", func(t *testing.T) { + link := filepath.Join(realDir, "link.go") + if err := os.Symlink(filepath.Join(realDir, "a.go"), link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if MutationKey(link) == MutationKey(filepath.Join(realDir, "a.go")) { + t.Fatalf("MutationKey followed the leaf symlink %q", link) + } + }) +} From 7dd80894902dc2422fe4e46327dec97caf32f024 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:53:03 +0100 Subject: [PATCH 109/138] test(mcp): retain pending projection foundation controls and fixtures --- internal/mcp/pending_projection_test.go | 437 ++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 internal/mcp/pending_projection_test.go diff --git a/internal/mcp/pending_projection_test.go b/internal/mcp/pending_projection_test.go new file mode 100644 index 000000000..13799e207 --- /dev/null +++ b/internal/mcp/pending_projection_test.go @@ -0,0 +1,437 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/pathkey" +) + +type projectionFixture struct { + t *testing.T + dir string + g *graph.Graph + idx *indexer.Indexer + seq uint64 +} + +// newProjectionFixture indexes a one-file Go repository into an in-memory +// store, the state before a tool edit. +func newProjectionFixture(t *testing.T) *projectionFixture { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) + g := graph.New() + idx := newProjectionTestIndexer(t, g) + _, err := idx.Index(dir) + require.NoError(t, err) + return &projectionFixture{t: t, dir: dir, g: g, idx: idx} +} + +func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg := config.Default().Index + cfg.Workers = 1 + idx := indexer.New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +// edit writes content to rel as a tool would and projects it. +func (f *projectionFixture) edit(rel, content string) *projectionEntry { + f.t.Helper() + path := filepath.Join(f.dir, rel) + require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(f.t, err) + f.seq++ + e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) + require.NoError(f.t, err) + return e +} + +func liveEntry(p *pendingProjections, path string) *projectionEntry { + return p.snap.Load().entries[pathkey.MutationKey(path)] +} + +type projectionRetirementSpy struct { + *graph.Graph + metadataReads int + nodeReads int +} + +func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + s.metadataReads++ + return s.Graph.FileMetasByPaths(prefix, paths) +} + +func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { + s.nodeReads++ + return s.Graph.GetFileNodes(path) +} + +func TestPendingProjectionRetirementUsesBackendContentEvidence(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + e := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(e)) + store := &projectionRetirementSpy{Graph: f.g} + wrapped := newContractCoreEdges(store, t.Context(), nil) + require.NotImplements(t, (*graph.FileMetaPathReader)(nil), wrapped, + "durable retirement evidence is backend state, not a request-view capability") + require.Zero(t, p.retire(store), "old durable bytes cannot retire a newer projection") + require.Equal(t, 1, store.metadataReads) + require.Zero(t, store.nodeReads, "mismatched hashes need no node comparison") + + require.NoError(t, f.idx.IndexFile(filepath.Join(f.dir, "a.go"))) + require.Equal(t, 1, p.retire(store)) + require.Equal(t, 3, store.metadataReads, "retirement brackets its node comparison with durable hashes") + require.Equal(t, 1, store.nodeReads) + require.Zero(t, p.divergence.Load()) + require.Nil(t, liveEntry(p, e.absPath)) +} + +func TestPendingProjectionPublishRetire(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + require.Nil(t, pendingProjectionHealth(p)) + path := filepath.Join(f.dir, "a.go") + + first := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(first)) + view := graph.NewOverlaidView(f.g, first.layer) + require.Nil(t, view.GetNode("a.go::Old")) + require.NotNil(t, view.GetNode("a.go::New")) + + t.Run("a newer entry replaces the older at once", func(t *testing.T) { + second := f.edit("a.go", "package p\n\nfunc Newer() {}\n") + require.NoError(t, p.publish(second)) + require.Same(t, second, liveEntry(p, path)) + require.ErrorIs(t, p.publish(first), projectionSuperseded) + require.Same(t, second, liveEntry(p, path)) + }) + + t.Run("a failed patch keeps the entry", func(t *testing.T) { + require.Zero(t, p.retire(f.g), "the store still holds the old bytes") + require.NotNil(t, liveEntry(p, path)) + }) + + t.Run("the entry retires once the store holds its bytes", func(t *testing.T) { + require.NoError(t, f.idx.IndexFile(path)) + require.Equal(t, 1, p.retire(f.g)) + require.Nil(t, liveEntry(p, path)) + require.Zero(t, p.divergence.Load()) + }) + + t.Run("a retired entry that disagrees with the store counts as divergence", func(t *testing.T) { + e := f.edit("a.go", "package p\n\nfunc Newest() {}\n") + require.NoError(t, f.idx.IndexFile(path)) + e.layer.AddNode(e.graphPath, &graph.Node{ID: "a.go::Phantom", Kind: graph.KindFunction, FilePath: "a.go"}) + require.NoError(t, p.publish(e)) + require.Equal(t, 1, p.retire(f.g)) + require.EqualValues(t, 1, p.divergence.Load()) + }) + + t.Run("a tombstone hides the file's symbols until the store drops the file", func(t *testing.T) { + require.NoError(t, os.Remove(path)) + f.seq++ + tomb := newProjectionTombstone(path, "a.go", "", f.seq) + require.NoError(t, p.publish(tomb)) + e, err := p.lookup(path) + require.NoError(t, err) + view := graph.NewOverlaidView(f.g, e.layer) + require.NotEmpty(t, f.g.GetFileNodes("a.go")) + require.Empty(t, view.GetFileNodes("a.go")) + require.Empty(t, view.FindNodesByName("Newest")) + require.Nil(t, view.GetNode("a.go::Newest")) + + require.Zero(t, p.retire(f.g)) + require.NoError(t, f.g.DeleteFileMetasByFiles("", []string{"a.go"})) + f.g.EvictFile("a.go") + require.Equal(t, 1, p.retire(f.g)) + }) + + t.Run("a case variant of a live entry is refused", func(t *testing.T) { + prev := pathkey.CaseInsensitivePaths + pathkey.CaseInsensitivePaths = true + t.Cleanup(func() { pathkey.CaseInsensitivePaths = prev }) + live := f.edit("b.go", "package p\n\nfunc B() {}\n") + require.NoError(t, p.publish(live)) + variant := newProjectionEntry(filepath.Join(f.dir, "B.go"), "", &indexer.Projection{GraphPath: "B.go"}, nil, f.seq+1, diskStamp{}) + require.ErrorIs(t, p.publish(variant), projectionPathCase) + require.Same(t, live, liveEntry(p, filepath.Join(f.dir, "b.go"))) + }) + + health := pendingProjectionHealth(p) + require.Equal(t, 1, health["files"]) + require.EqualValues(t, 3, health["retired"]) + require.EqualValues(t, 1, health["projection_divergence"]) + require.Equal(t, map[string]int64{"superseded": 1, "path_case": 1}, health["refused"]) +} + +func TestPendingProjectionLookupChecksDisk(t *testing.T) { + f := newProjectionFixture(t) + path := filepath.Join(f.dir, "a.go") + const content = "package p\n\nfunc Edited() {}\n" + sameSize := "package p\n\nfunc Changed() {}\n"[:len(content)] + + for _, tc := range []struct { + name string + suspect bool + change func(t *testing.T, mtime time.Time) + served bool + }{ + {name: "unchanged", served: true}, + {name: "unchanged but suspect", suspect: true, served: true}, + {name: "size", change: func(t *testing.T, _ time.Time) { + require.NoError(t, os.WriteFile(path, []byte(content+"// more\n"), 0o644)) + }}, + {name: "mtime", change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.Chtimes(path, mtime, mtime.Add(time.Second))) + }}, + {name: "inode", change: func(t *testing.T, mtime time.Time) { + replacement := path + ".tmp" + require.NoError(t, os.WriteFile(replacement, []byte(content), 0o644)) + require.NoError(t, os.Chtimes(replacement, mtime, mtime)) + require.NoError(t, os.Rename(replacement, path)) + }}, + {name: "same stat, other bytes, after a watcher event", suspect: true, change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.WriteFile(path, []byte(sameSize), 0o644)) + require.NoError(t, os.Chtimes(path, mtime, mtime)) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newPendingProjections() + e := f.edit("a.go", content) + require.NoError(t, p.publish(e)) + if tc.change != nil { + tc.change(t, e.disk.info.ModTime()) + } + if tc.suspect { + p.markSuspect(path) + } + got, err := p.lookup(path) + if tc.served { + require.NoError(t, err) + require.Same(t, e, got) + return + } + require.ErrorIs(t, err, errProjectionExternalWrite) + require.Nil(t, got) + require.Nil(t, liveEntry(p, path), "a stale entry must not be served again") + require.EqualValues(t, 1, p.externalWrites.Load()) + }) + } +} + +func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { + node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} + projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} + return newProjectionEntry(path, "", projection, raw, seq, stamp) +} + +func TestPendingProjectionCapsRefuseWithoutEviction(t *testing.T) { + dir := t.TempDir() + p := newPendingProjections() + for i := range projectionMaxFiles { + name := fmt.Sprintf("f%03d.go", i) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, name), name, "h", 1, diskStamp{}, nil))) + } + over := syntheticProjectionEntry(filepath.Join(dir, "over.go"), "over.go", "h", 1, diskStamp{}, nil) + require.ErrorIs(t, p.publish(over), projectionOverFiles) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, "f000.go"), "f000.go", "h", 2, diskStamp{}, nil)), + "replacing a live file at the cap is not growth") + + huge := syntheticProjectionEntry(filepath.Join(dir, "f001.go"), "f001.go", "h", 2, diskStamp{}, nil) + huge.size = projectionMaxBytes + require.ErrorIs(t, p.publish(huge), projectionOverBytes) + + snap := p.snap.Load() + require.Len(t, snap.entries, projectionMaxFiles) + require.NotSame(t, huge, liveEntry(p, filepath.Join(dir, "f001.go"))) + require.Nil(t, liveEntry(p, filepath.Join(dir, "over.go"))) +} + +func TestPendingProjectionKillSwitch(t *testing.T) { + t.Setenv("GORTEX_PROJECTION", "0") + p := newPendingProjections() + path := filepath.Join(t.TempDir(), "a.go") + require.ErrorIs(t, p.publish(syntheticProjectionEntry(path, "a.go", "h", 1, diskStamp{}, nil)), projectionDisabled) + require.Empty(t, p.snap.Load().entries) + e, err := p.lookup(path) + require.NoError(t, err) + require.Nil(t, e) +} + +// storeTripwire forwards to a real store until it is cleared; any store call +// after that panics. +type storeTripwire struct{ graph.Store } + +func TestProjectFileReadsNoStore(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.go") + content := []byte("package p\n\nimport \"fmt\"\n\nfunc F() { fmt.Println(G()) }\n\nfunc G() int { return 1 }\n") + require.NoError(t, os.WriteFile(path, content, 0o644)) + store := &storeTripwire{Store: graph.New()} + idx := newProjectionTestIndexer(t, store) + idx.SetRootPath(dir) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + p := newPendingProjections() + + backing := store.Store + store.Store = nil + func() { + defer func() { + store.Store = backing + if r := recover(); r != nil { + t.Fatalf("store accessed while building or publishing a projection: %v", r) + } + }() + e, err := projectFile(context.Background(), idx, path, content, 1, stamp) + require.NoError(t, err) + require.NoError(t, p.publish(e)) + got, err := p.lookup(path) + require.NoError(t, err) + require.Same(t, e, got) + require.NotEmpty(t, got.layer.NodesByName("G")) + }() +} + +// 64 goroutines publish, retire, mark and read at once. Readers never see a +// torn snapshot, and retirement never drops an entry a newer publish put in +// place: every file ends with its newest entry. +func TestPendingProjectionConcurrentPublishRetireRead(t *testing.T) { + const ( + files = 16 + publishers = 32 + rounds = 50 + retirers = 16 + readers = 16 + ) + dir := t.TempDir() + store := graph.New() + paths := make([]string, files) + stamps := make([]diskStamp, files) + raw := []byte("package p\n") + for i := range files { + name := fmt.Sprintf("f%02d.go", i) + paths[i] = filepath.Join(dir, name) + require.NoError(t, os.WriteFile(paths[i], raw, 0o644)) + stamp, err := statDiskStamp(paths[i]) + require.NoError(t, err) + stamps[i] = stamp + store.AddNode(&graph.Node{ID: name, Kind: graph.KindFile, FilePath: name, StartLine: 1, EndLine: 1}) + require.NoError(t, store.SetFileMetas("", []graph.FileMetaRow{{FilePath: name, ContentHash: "stale"}})) + } + + p := newPendingProjections() + var seq atomic.Uint64 + last := make([]atomic.Uint64, publishers) + var publishing, background sync.WaitGroup + done := make(chan struct{}) + for w := range publishers { + publishing.Add(1) + go func() { + defer publishing.Done() + file := w % files + for r := range rounds { + s := seq.Add(1) + // Only the last round's bytes are newer than the store's, so + // that entry must survive retirement. + hash := "stale" + if r == rounds-1 { + hash = "fresh" + last[w].Store(s) + } + e := syntheticProjectionEntry(paths[file], filepath.Base(paths[file]), hash, s, stamps[file], raw) + if err := p.publish(e); err != nil && !errors.Is(err, projectionSuperseded) { + t.Errorf("publish: %v", err) + } + } + }() + } + for range retirers { + background.Add(1) + go func() { + defer background.Done() + for { + select { + case <-done: + return + default: + p.retire(store) + } + } + }() + } + for r := range readers { + background.Add(1) + go func() { + defer background.Done() + for i := 0; ; i++ { + select { + case <-done: + return + default: + } + path := paths[(r+i)%files] + if i%3 == 0 { + p.markSuspect(path) + } + e, err := p.lookup(path) + if err != nil { + t.Errorf("lookup %s: %v", path, err) + return + } + if e != nil && (e.key != pathkey.MutationKey(path) || len(e.layer.FileNodes(e.graphPath)) != 1) { + t.Errorf("lookup %s served a torn entry", path) + return + } + snap := p.snap.Load() + var bytes int64 + for _, e := range snap.entries { + bytes += e.size + } + if bytes != snap.bytes { + t.Errorf("snapshot bytes %d, entries sum to %d", snap.bytes, bytes) + return + } + } + }() + } + publishing.Wait() + close(done) + background.Wait() + p.retire(store) + + for file, path := range paths { + var newest uint64 + for w := range publishers { + if w%files == file { + newest = max(newest, last[w].Load()) + } + } + e := liveEntry(p, path) + require.NotNil(t, e, "entry for %s was lost", path) + require.Equal(t, newest, e.commitSeq, path) + require.Equal(t, "fresh", e.hash, path) + } +} From 7586999ac81903249dfd61d5181e4eabe329ec6b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 03:54:48 +0100 Subject: [PATCH 110/138] test(mcp): share only bounded source projection fixtures --- internal/mcp/pending_projection_test.go | 437 ------------------ .../mcp/source_projection_fixtures_test.go | 89 ++++ 2 files changed, 89 insertions(+), 437 deletions(-) delete mode 100644 internal/mcp/pending_projection_test.go create mode 100644 internal/mcp/source_projection_fixtures_test.go diff --git a/internal/mcp/pending_projection_test.go b/internal/mcp/pending_projection_test.go deleted file mode 100644 index 13799e207..000000000 --- a/internal/mcp/pending_projection_test.go +++ /dev/null @@ -1,437 +0,0 @@ -package mcp - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/zzet/gortex/internal/config" - "github.com/zzet/gortex/internal/graph" - "github.com/zzet/gortex/internal/indexer" - "github.com/zzet/gortex/internal/parser" - "github.com/zzet/gortex/internal/parser/languages" - "github.com/zzet/gortex/internal/pathkey" -) - -type projectionFixture struct { - t *testing.T - dir string - g *graph.Graph - idx *indexer.Indexer - seq uint64 -} - -// newProjectionFixture indexes a one-file Go repository into an in-memory -// store, the state before a tool edit. -func newProjectionFixture(t *testing.T) *projectionFixture { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) - g := graph.New() - idx := newProjectionTestIndexer(t, g) - _, err := idx.Index(dir) - require.NoError(t, err) - return &projectionFixture{t: t, dir: dir, g: g, idx: idx} -} - -func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { - t.Helper() - registry := parser.NewRegistry() - languages.RegisterAll(registry) - cfg := config.Default().Index - cfg.Workers = 1 - idx := indexer.New(g, registry, cfg, zap.NewNop()) - t.Cleanup(idx.Close) - return idx -} - -// edit writes content to rel as a tool would and projects it. -func (f *projectionFixture) edit(rel, content string) *projectionEntry { - f.t.Helper() - path := filepath.Join(f.dir, rel) - require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) - stamp, err := statDiskStamp(path) - require.NoError(f.t, err) - f.seq++ - e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) - require.NoError(f.t, err) - return e -} - -func liveEntry(p *pendingProjections, path string) *projectionEntry { - return p.snap.Load().entries[pathkey.MutationKey(path)] -} - -type projectionRetirementSpy struct { - *graph.Graph - metadataReads int - nodeReads int -} - -func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { - s.metadataReads++ - return s.Graph.FileMetasByPaths(prefix, paths) -} - -func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { - s.nodeReads++ - return s.Graph.GetFileNodes(path) -} - -func TestPendingProjectionRetirementUsesBackendContentEvidence(t *testing.T) { - f := newProjectionFixture(t) - p := newPendingProjections() - e := f.edit("a.go", "package p\n\nfunc New() {}\n") - require.NoError(t, p.publish(e)) - store := &projectionRetirementSpy{Graph: f.g} - wrapped := newContractCoreEdges(store, t.Context(), nil) - require.NotImplements(t, (*graph.FileMetaPathReader)(nil), wrapped, - "durable retirement evidence is backend state, not a request-view capability") - require.Zero(t, p.retire(store), "old durable bytes cannot retire a newer projection") - require.Equal(t, 1, store.metadataReads) - require.Zero(t, store.nodeReads, "mismatched hashes need no node comparison") - - require.NoError(t, f.idx.IndexFile(filepath.Join(f.dir, "a.go"))) - require.Equal(t, 1, p.retire(store)) - require.Equal(t, 3, store.metadataReads, "retirement brackets its node comparison with durable hashes") - require.Equal(t, 1, store.nodeReads) - require.Zero(t, p.divergence.Load()) - require.Nil(t, liveEntry(p, e.absPath)) -} - -func TestPendingProjectionPublishRetire(t *testing.T) { - f := newProjectionFixture(t) - p := newPendingProjections() - require.Nil(t, pendingProjectionHealth(p)) - path := filepath.Join(f.dir, "a.go") - - first := f.edit("a.go", "package p\n\nfunc New() {}\n") - require.NoError(t, p.publish(first)) - view := graph.NewOverlaidView(f.g, first.layer) - require.Nil(t, view.GetNode("a.go::Old")) - require.NotNil(t, view.GetNode("a.go::New")) - - t.Run("a newer entry replaces the older at once", func(t *testing.T) { - second := f.edit("a.go", "package p\n\nfunc Newer() {}\n") - require.NoError(t, p.publish(second)) - require.Same(t, second, liveEntry(p, path)) - require.ErrorIs(t, p.publish(first), projectionSuperseded) - require.Same(t, second, liveEntry(p, path)) - }) - - t.Run("a failed patch keeps the entry", func(t *testing.T) { - require.Zero(t, p.retire(f.g), "the store still holds the old bytes") - require.NotNil(t, liveEntry(p, path)) - }) - - t.Run("the entry retires once the store holds its bytes", func(t *testing.T) { - require.NoError(t, f.idx.IndexFile(path)) - require.Equal(t, 1, p.retire(f.g)) - require.Nil(t, liveEntry(p, path)) - require.Zero(t, p.divergence.Load()) - }) - - t.Run("a retired entry that disagrees with the store counts as divergence", func(t *testing.T) { - e := f.edit("a.go", "package p\n\nfunc Newest() {}\n") - require.NoError(t, f.idx.IndexFile(path)) - e.layer.AddNode(e.graphPath, &graph.Node{ID: "a.go::Phantom", Kind: graph.KindFunction, FilePath: "a.go"}) - require.NoError(t, p.publish(e)) - require.Equal(t, 1, p.retire(f.g)) - require.EqualValues(t, 1, p.divergence.Load()) - }) - - t.Run("a tombstone hides the file's symbols until the store drops the file", func(t *testing.T) { - require.NoError(t, os.Remove(path)) - f.seq++ - tomb := newProjectionTombstone(path, "a.go", "", f.seq) - require.NoError(t, p.publish(tomb)) - e, err := p.lookup(path) - require.NoError(t, err) - view := graph.NewOverlaidView(f.g, e.layer) - require.NotEmpty(t, f.g.GetFileNodes("a.go")) - require.Empty(t, view.GetFileNodes("a.go")) - require.Empty(t, view.FindNodesByName("Newest")) - require.Nil(t, view.GetNode("a.go::Newest")) - - require.Zero(t, p.retire(f.g)) - require.NoError(t, f.g.DeleteFileMetasByFiles("", []string{"a.go"})) - f.g.EvictFile("a.go") - require.Equal(t, 1, p.retire(f.g)) - }) - - t.Run("a case variant of a live entry is refused", func(t *testing.T) { - prev := pathkey.CaseInsensitivePaths - pathkey.CaseInsensitivePaths = true - t.Cleanup(func() { pathkey.CaseInsensitivePaths = prev }) - live := f.edit("b.go", "package p\n\nfunc B() {}\n") - require.NoError(t, p.publish(live)) - variant := newProjectionEntry(filepath.Join(f.dir, "B.go"), "", &indexer.Projection{GraphPath: "B.go"}, nil, f.seq+1, diskStamp{}) - require.ErrorIs(t, p.publish(variant), projectionPathCase) - require.Same(t, live, liveEntry(p, filepath.Join(f.dir, "b.go"))) - }) - - health := pendingProjectionHealth(p) - require.Equal(t, 1, health["files"]) - require.EqualValues(t, 3, health["retired"]) - require.EqualValues(t, 1, health["projection_divergence"]) - require.Equal(t, map[string]int64{"superseded": 1, "path_case": 1}, health["refused"]) -} - -func TestPendingProjectionLookupChecksDisk(t *testing.T) { - f := newProjectionFixture(t) - path := filepath.Join(f.dir, "a.go") - const content = "package p\n\nfunc Edited() {}\n" - sameSize := "package p\n\nfunc Changed() {}\n"[:len(content)] - - for _, tc := range []struct { - name string - suspect bool - change func(t *testing.T, mtime time.Time) - served bool - }{ - {name: "unchanged", served: true}, - {name: "unchanged but suspect", suspect: true, served: true}, - {name: "size", change: func(t *testing.T, _ time.Time) { - require.NoError(t, os.WriteFile(path, []byte(content+"// more\n"), 0o644)) - }}, - {name: "mtime", change: func(t *testing.T, mtime time.Time) { - require.NoError(t, os.Chtimes(path, mtime, mtime.Add(time.Second))) - }}, - {name: "inode", change: func(t *testing.T, mtime time.Time) { - replacement := path + ".tmp" - require.NoError(t, os.WriteFile(replacement, []byte(content), 0o644)) - require.NoError(t, os.Chtimes(replacement, mtime, mtime)) - require.NoError(t, os.Rename(replacement, path)) - }}, - {name: "same stat, other bytes, after a watcher event", suspect: true, change: func(t *testing.T, mtime time.Time) { - require.NoError(t, os.WriteFile(path, []byte(sameSize), 0o644)) - require.NoError(t, os.Chtimes(path, mtime, mtime)) - }}, - } { - t.Run(tc.name, func(t *testing.T) { - p := newPendingProjections() - e := f.edit("a.go", content) - require.NoError(t, p.publish(e)) - if tc.change != nil { - tc.change(t, e.disk.info.ModTime()) - } - if tc.suspect { - p.markSuspect(path) - } - got, err := p.lookup(path) - if tc.served { - require.NoError(t, err) - require.Same(t, e, got) - return - } - require.ErrorIs(t, err, errProjectionExternalWrite) - require.Nil(t, got) - require.Nil(t, liveEntry(p, path), "a stale entry must not be served again") - require.EqualValues(t, 1, p.externalWrites.Load()) - }) - } -} - -func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { - node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} - projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} - return newProjectionEntry(path, "", projection, raw, seq, stamp) -} - -func TestPendingProjectionCapsRefuseWithoutEviction(t *testing.T) { - dir := t.TempDir() - p := newPendingProjections() - for i := range projectionMaxFiles { - name := fmt.Sprintf("f%03d.go", i) - require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, name), name, "h", 1, diskStamp{}, nil))) - } - over := syntheticProjectionEntry(filepath.Join(dir, "over.go"), "over.go", "h", 1, diskStamp{}, nil) - require.ErrorIs(t, p.publish(over), projectionOverFiles) - require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, "f000.go"), "f000.go", "h", 2, diskStamp{}, nil)), - "replacing a live file at the cap is not growth") - - huge := syntheticProjectionEntry(filepath.Join(dir, "f001.go"), "f001.go", "h", 2, diskStamp{}, nil) - huge.size = projectionMaxBytes - require.ErrorIs(t, p.publish(huge), projectionOverBytes) - - snap := p.snap.Load() - require.Len(t, snap.entries, projectionMaxFiles) - require.NotSame(t, huge, liveEntry(p, filepath.Join(dir, "f001.go"))) - require.Nil(t, liveEntry(p, filepath.Join(dir, "over.go"))) -} - -func TestPendingProjectionKillSwitch(t *testing.T) { - t.Setenv("GORTEX_PROJECTION", "0") - p := newPendingProjections() - path := filepath.Join(t.TempDir(), "a.go") - require.ErrorIs(t, p.publish(syntheticProjectionEntry(path, "a.go", "h", 1, diskStamp{}, nil)), projectionDisabled) - require.Empty(t, p.snap.Load().entries) - e, err := p.lookup(path) - require.NoError(t, err) - require.Nil(t, e) -} - -// storeTripwire forwards to a real store until it is cleared; any store call -// after that panics. -type storeTripwire struct{ graph.Store } - -func TestProjectFileReadsNoStore(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "a.go") - content := []byte("package p\n\nimport \"fmt\"\n\nfunc F() { fmt.Println(G()) }\n\nfunc G() int { return 1 }\n") - require.NoError(t, os.WriteFile(path, content, 0o644)) - store := &storeTripwire{Store: graph.New()} - idx := newProjectionTestIndexer(t, store) - idx.SetRootPath(dir) - stamp, err := statDiskStamp(path) - require.NoError(t, err) - p := newPendingProjections() - - backing := store.Store - store.Store = nil - func() { - defer func() { - store.Store = backing - if r := recover(); r != nil { - t.Fatalf("store accessed while building or publishing a projection: %v", r) - } - }() - e, err := projectFile(context.Background(), idx, path, content, 1, stamp) - require.NoError(t, err) - require.NoError(t, p.publish(e)) - got, err := p.lookup(path) - require.NoError(t, err) - require.Same(t, e, got) - require.NotEmpty(t, got.layer.NodesByName("G")) - }() -} - -// 64 goroutines publish, retire, mark and read at once. Readers never see a -// torn snapshot, and retirement never drops an entry a newer publish put in -// place: every file ends with its newest entry. -func TestPendingProjectionConcurrentPublishRetireRead(t *testing.T) { - const ( - files = 16 - publishers = 32 - rounds = 50 - retirers = 16 - readers = 16 - ) - dir := t.TempDir() - store := graph.New() - paths := make([]string, files) - stamps := make([]diskStamp, files) - raw := []byte("package p\n") - for i := range files { - name := fmt.Sprintf("f%02d.go", i) - paths[i] = filepath.Join(dir, name) - require.NoError(t, os.WriteFile(paths[i], raw, 0o644)) - stamp, err := statDiskStamp(paths[i]) - require.NoError(t, err) - stamps[i] = stamp - store.AddNode(&graph.Node{ID: name, Kind: graph.KindFile, FilePath: name, StartLine: 1, EndLine: 1}) - require.NoError(t, store.SetFileMetas("", []graph.FileMetaRow{{FilePath: name, ContentHash: "stale"}})) - } - - p := newPendingProjections() - var seq atomic.Uint64 - last := make([]atomic.Uint64, publishers) - var publishing, background sync.WaitGroup - done := make(chan struct{}) - for w := range publishers { - publishing.Add(1) - go func() { - defer publishing.Done() - file := w % files - for r := range rounds { - s := seq.Add(1) - // Only the last round's bytes are newer than the store's, so - // that entry must survive retirement. - hash := "stale" - if r == rounds-1 { - hash = "fresh" - last[w].Store(s) - } - e := syntheticProjectionEntry(paths[file], filepath.Base(paths[file]), hash, s, stamps[file], raw) - if err := p.publish(e); err != nil && !errors.Is(err, projectionSuperseded) { - t.Errorf("publish: %v", err) - } - } - }() - } - for range retirers { - background.Add(1) - go func() { - defer background.Done() - for { - select { - case <-done: - return - default: - p.retire(store) - } - } - }() - } - for r := range readers { - background.Add(1) - go func() { - defer background.Done() - for i := 0; ; i++ { - select { - case <-done: - return - default: - } - path := paths[(r+i)%files] - if i%3 == 0 { - p.markSuspect(path) - } - e, err := p.lookup(path) - if err != nil { - t.Errorf("lookup %s: %v", path, err) - return - } - if e != nil && (e.key != pathkey.MutationKey(path) || len(e.layer.FileNodes(e.graphPath)) != 1) { - t.Errorf("lookup %s served a torn entry", path) - return - } - snap := p.snap.Load() - var bytes int64 - for _, e := range snap.entries { - bytes += e.size - } - if bytes != snap.bytes { - t.Errorf("snapshot bytes %d, entries sum to %d", snap.bytes, bytes) - return - } - } - }() - } - publishing.Wait() - close(done) - background.Wait() - p.retire(store) - - for file, path := range paths { - var newest uint64 - for w := range publishers { - if w%files == file { - newest = max(newest, last[w].Load()) - } - } - e := liveEntry(p, path) - require.NotNil(t, e, "entry for %s was lost", path) - require.Equal(t, newest, e.commitSeq, path) - require.Equal(t, "fresh", e.hash, path) - } -} diff --git a/internal/mcp/source_projection_fixtures_test.go b/internal/mcp/source_projection_fixtures_test.go new file mode 100644 index 000000000..04cc472d5 --- /dev/null +++ b/internal/mcp/source_projection_fixtures_test.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/pathkey" +) + +type projectionFixture struct { + t *testing.T + dir string + g *graph.Graph + idx *indexer.Indexer + seq uint64 +} + +// newProjectionFixture indexes a one-file Go repository into an in-memory +// store, the state before a tool edit. +func newProjectionFixture(t *testing.T) *projectionFixture { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) + g := graph.New() + idx := newProjectionTestIndexer(t, g) + _, err := idx.Index(dir) + require.NoError(t, err) + return &projectionFixture{t: t, dir: dir, g: g, idx: idx} +} + +func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg := config.Default().Index + cfg.Workers = 1 + idx := indexer.New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +// edit writes content to rel as a tool would and projects it. +func (f *projectionFixture) edit(rel, content string) *projectionEntry { + f.t.Helper() + path := filepath.Join(f.dir, rel) + require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(f.t, err) + f.seq++ + e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) + require.NoError(f.t, err) + return e +} + +func liveEntry(p *pendingProjections, path string) *projectionEntry { + return p.snap.Load().entries[pathkey.MutationKey(path)] +} + +type projectionRetirementSpy struct { + *graph.Graph + metadataReads int + nodeReads int +} + +func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + s.metadataReads++ + return s.Graph.FileMetasByPaths(prefix, paths) +} + +func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { + s.nodeReads++ + return s.Graph.GetFileNodes(path) +} + +func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { + node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} + projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} + return newProjectionEntry(path, "", projection, raw, seq, stamp) +} From 291d9af6538c09287ebefa0f6cd1d6dbc554af9b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:02:05 +0100 Subject: [PATCH 111/138] fix(indexer): restore native source event invalidation callbacks --- internal/indexer/multi_watcher.go | 21 ++++ internal/indexer/watcher.go | 20 ++++ internal/indexer/watcher_source_event_test.go | 100 ++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 internal/indexer/watcher_source_event_test.go diff --git a/internal/indexer/multi_watcher.go b/internal/indexer/multi_watcher.go index 15c531347..676c6a70a 100644 --- a/internal/indexer/multi_watcher.go +++ b/internal/indexer/multi_watcher.go @@ -133,6 +133,7 @@ type MultiWatcher struct { // atomically with an ownership-epoch dispatch admission. callbackMu sync.Mutex symbolChangeCb SymbolChangeCallback + sourceEventCb func(string) // degradedCb is fanned out to every per-repo Watcher (and re-applied at // AddRepo) so a watcher entering a degraded state — inotify / FD // exhaustion — pushes a single health notice through the daemon. @@ -233,6 +234,9 @@ func (mw *MultiWatcher) createWatcher(prefix string, cfg config.WatchConfig) err if err != nil { return fmt.Errorf("creating watcher for %s: %w", prefix, err) } + // Resolve the current callback at delivery time: replacements and future + // watchers share registration without a stale fanout snapshot. + w.OnSourceEvent(mw.notifySourceEvent) if mw.configManagedExcludes { mw.configureManagedExcludes(prefix, w) } @@ -1021,6 +1025,23 @@ func (mw *MultiWatcher) HistorySince(since time.Time) []GraphChangeEvent { return out } +// OnSourceEvent replaces the synchronous source-path callback for every +// current and future per-repo watcher. The callback must return promptly. +func (mw *MultiWatcher) OnSourceEvent(cb func(string)) { + mw.callbackMu.Lock() + mw.sourceEventCb = cb + mw.callbackMu.Unlock() +} + +func (mw *MultiWatcher) notifySourceEvent(path string) { + mw.callbackMu.Lock() + cb := mw.sourceEventCb + mw.callbackMu.Unlock() + if cb != nil { + cb(path) + } +} + // OnSymbolChange registers the callback against every current per-repo // Watcher and stores it so AddRepo applies it to future watchers too. // Replaces any previously registered callback (matches Watcher.OnSymbolChange diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index aa288faa7..4912e78be 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -222,6 +222,8 @@ type Watcher struct { stoppedOnce sync.Once symbolChangeCb SymbolChangeCallback symbolChangeCbMu sync.RWMutex + sourceEventCb func(string) + sourceEventCbMu sync.RWMutex // Degraded-watch state: set when the OS can't fully cover the tree — // inotify watch exhaustion (ENOSPC) or FD exhaustion (EMFILE/ENFILE). @@ -1314,6 +1316,23 @@ func (w *Watcher) OnSymbolChange(cb SymbolChangeCallback) { w.symbolChangeCb = cb } +// OnSourceEvent replaces the callback invoked synchronously for a source path +// before storm handling or debounce. The callback must return promptly. +func (w *Watcher) OnSourceEvent(cb func(string)) { + w.sourceEventCbMu.Lock() + w.sourceEventCb = cb + w.sourceEventCbMu.Unlock() +} + +func (w *Watcher) notifySourceEvent(path string) { + w.sourceEventCbMu.RLock() + cb := w.sourceEventCb + w.sourceEventCbMu.RUnlock() + if cb != nil { + cb(path) + } +} + // OnDegraded registers a callback invoked once when the file watcher first // enters a degraded state (inotify / FD exhaustion, a stopped backend, an // incomplete startup barrier). The daemon wires it to its health @@ -1738,6 +1757,7 @@ func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { } return } + w.notifySourceEvent(path) // Before the exclusion check: an ignore file can sit in a folder whose // own events are excluded, and its rules apply all the same. diff --git a/internal/indexer/watcher_source_event_test.go b/internal/indexer/watcher_source_event_test.go new file mode 100644 index 000000000..e94ea5288 --- /dev/null +++ b/internal/indexer/watcher_source_event_test.go @@ -0,0 +1,100 @@ +package indexer + +import ( + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestWatcherSourceEventConcurrentRegistration(t *testing.T) { + w := &Watcher{} + var calls atomic.Int64 + callback := func(string) { calls.Add(1) } + var done sync.WaitGroup + done.Add(1) + go func() { + defer done.Done() + for range 100 { + w.OnSourceEvent(callback) + w.OnSourceEvent(nil) + } + }() + for range 100 { + w.notifySourceEvent("a.go") + } + done.Wait() + w.OnSourceEvent(callback) + before := calls.Load() + w.notifySourceEvent("a.go") + require.Equal(t, before+1, calls.Load()) + w.OnSourceEvent(nil) + w.notifySourceEvent("a.go") + require.Equal(t, before+1, calls.Load()) +} + +func TestMultiWatcherSourceEventRegistrationFollowsNewWatchers(t *testing.T) { + mw, mi, repoA, _ := setupMultiWatcherTest(t) + t.Cleanup(func() { _ = mw.Stop() }) + var first, second atomic.Int64 + eventPath := filepath.Join(repoA, "delivered.go") + mw.OnSourceEvent(func(path string) { + if path == eventPath { + first.Add(1) + } + }) + mw.watchers["repo-a"].notifySourceEvent(eventPath) + require.EqualValues(t, 1, first.Load()) + + // Replacement takes effect on already-created watchers. A callback may + // register its successor because delivery holds no registration lock. + callback := func(path string) { + if path == eventPath { + second.Add(1) + } + } + mw.OnSourceEvent(func(path string) { + if path == eventPath { + second.Add(1) + mw.OnSourceEvent(callback) + } + }) + mw.watchers["repo-a"].notifySourceEvent(eventPath) + require.EqualValues(t, 1, first.Load()) + require.EqualValues(t, 1, second.Load()) + + newRepo := t.TempDir() + writeFile(t, filepath.Join(newRepo, "main.go"), "package main\nfunc Added() {}\n") + _, err := mi.TrackRepo(config.RepoEntry{Path: newRepo, Name: "repo-new"}) + require.NoError(t, err) + require.NoError(t, mw.AddRepo("repo-new", config.WatchConfig{Enabled: true, DebounceMs: 60_000})) + mw.mu.Lock() + added := mw.watchers["repo-new"] + mw.mu.Unlock() + require.NotNil(t, added) + before := second.Load() + added.notifySourceEvent(eventPath) + require.Equal(t, before+1, second.Load()) + + var done sync.WaitGroup + done.Add(1) + go func() { + defer done.Done() + for range 100 { + mw.OnSourceEvent(callback) + mw.OnSourceEvent(nil) + } + }() + for range 100 { + added.notifySourceEvent(eventPath) + } + done.Wait() + mw.OnSourceEvent(nil) + before = second.Load() + added.notifySourceEvent(eventPath) + require.Equal(t, before, second.Load()) +} From 2114d6852aba0b83fbde066d13bd920da6cb6189 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:06:25 +0100 Subject: [PATCH 112/138] fix(mcp): preserve pending projection health and original coverage --- internal/mcp/index_file_failures.go | 38 ++ internal/mcp/pending_projection_test.go | 437 ++++++++++++++++++ .../mcp/source_projection_fixtures_test.go | 89 ---- 3 files changed, 475 insertions(+), 89 deletions(-) create mode 100644 internal/mcp/pending_projection_test.go delete mode 100644 internal/mcp/source_projection_fixtures_test.go diff --git a/internal/mcp/index_file_failures.go b/internal/mcp/index_file_failures.go index 463074df1..1975a6b0f 100644 --- a/internal/mcp/index_file_failures.go +++ b/internal/mcp/index_file_failures.go @@ -329,5 +329,43 @@ func (s *Server) refreshIndexHealthFileFailures(ctx context.Context, baseline ma result["index_state_read_error"] = readErr.Error() result["recommendation"] = strings.TrimSpace("Index failure state could not be read; index completeness is unknown. " + readErr.Error() + ". " + recommendation) } + if section := pendingProjectionHealth(pendingFileProjections); section != nil { + result["pending_projection"] = section + } return result } + +// pendingProjectionHealth reports the pending projections: live entries and +// their estimated size, what publishing refused and why, and how many +// retired entries disagreed with the store. It is nil until the registry has +// done anything, and it reads only in-memory state. +func pendingProjectionHealth(p *pendingProjections) map[string]any { + snap := p.snap.Load() + p.mu.Lock() + refused := make(map[string]int64, len(p.refused)) + for reason, n := range p.refused { + refused[string(reason)] = n + } + p.mu.Unlock() + published := p.published.Load() + if len(snap.entries) == 0 && published == 0 && len(refused) == 0 { + return nil + } + tombstones := 0 + for _, e := range snap.entries { + if e.tombstone { + tombstones++ + } + } + return map[string]any{ + "enabled": p.enabled, + "files": len(snap.entries) - tombstones, + "tombstones": tombstones, + "bytes": snap.bytes, + "published": published, + "refused": refused, + "retired": p.retired.Load(), + "external_writes": p.externalWrites.Load(), + "projection_divergence": p.divergence.Load(), + } +} diff --git a/internal/mcp/pending_projection_test.go b/internal/mcp/pending_projection_test.go new file mode 100644 index 000000000..13799e207 --- /dev/null +++ b/internal/mcp/pending_projection_test.go @@ -0,0 +1,437 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/pathkey" +) + +type projectionFixture struct { + t *testing.T + dir string + g *graph.Graph + idx *indexer.Indexer + seq uint64 +} + +// newProjectionFixture indexes a one-file Go repository into an in-memory +// store, the state before a tool edit. +func newProjectionFixture(t *testing.T) *projectionFixture { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) + g := graph.New() + idx := newProjectionTestIndexer(t, g) + _, err := idx.Index(dir) + require.NoError(t, err) + return &projectionFixture{t: t, dir: dir, g: g, idx: idx} +} + +func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { + t.Helper() + registry := parser.NewRegistry() + languages.RegisterAll(registry) + cfg := config.Default().Index + cfg.Workers = 1 + idx := indexer.New(g, registry, cfg, zap.NewNop()) + t.Cleanup(idx.Close) + return idx +} + +// edit writes content to rel as a tool would and projects it. +func (f *projectionFixture) edit(rel, content string) *projectionEntry { + f.t.Helper() + path := filepath.Join(f.dir, rel) + require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) + stamp, err := statDiskStamp(path) + require.NoError(f.t, err) + f.seq++ + e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) + require.NoError(f.t, err) + return e +} + +func liveEntry(p *pendingProjections, path string) *projectionEntry { + return p.snap.Load().entries[pathkey.MutationKey(path)] +} + +type projectionRetirementSpy struct { + *graph.Graph + metadataReads int + nodeReads int +} + +func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { + s.metadataReads++ + return s.Graph.FileMetasByPaths(prefix, paths) +} + +func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { + s.nodeReads++ + return s.Graph.GetFileNodes(path) +} + +func TestPendingProjectionRetirementUsesBackendContentEvidence(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + e := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(e)) + store := &projectionRetirementSpy{Graph: f.g} + wrapped := newContractCoreEdges(store, t.Context(), nil) + require.NotImplements(t, (*graph.FileMetaPathReader)(nil), wrapped, + "durable retirement evidence is backend state, not a request-view capability") + require.Zero(t, p.retire(store), "old durable bytes cannot retire a newer projection") + require.Equal(t, 1, store.metadataReads) + require.Zero(t, store.nodeReads, "mismatched hashes need no node comparison") + + require.NoError(t, f.idx.IndexFile(filepath.Join(f.dir, "a.go"))) + require.Equal(t, 1, p.retire(store)) + require.Equal(t, 3, store.metadataReads, "retirement brackets its node comparison with durable hashes") + require.Equal(t, 1, store.nodeReads) + require.Zero(t, p.divergence.Load()) + require.Nil(t, liveEntry(p, e.absPath)) +} + +func TestPendingProjectionPublishRetire(t *testing.T) { + f := newProjectionFixture(t) + p := newPendingProjections() + require.Nil(t, pendingProjectionHealth(p)) + path := filepath.Join(f.dir, "a.go") + + first := f.edit("a.go", "package p\n\nfunc New() {}\n") + require.NoError(t, p.publish(first)) + view := graph.NewOverlaidView(f.g, first.layer) + require.Nil(t, view.GetNode("a.go::Old")) + require.NotNil(t, view.GetNode("a.go::New")) + + t.Run("a newer entry replaces the older at once", func(t *testing.T) { + second := f.edit("a.go", "package p\n\nfunc Newer() {}\n") + require.NoError(t, p.publish(second)) + require.Same(t, second, liveEntry(p, path)) + require.ErrorIs(t, p.publish(first), projectionSuperseded) + require.Same(t, second, liveEntry(p, path)) + }) + + t.Run("a failed patch keeps the entry", func(t *testing.T) { + require.Zero(t, p.retire(f.g), "the store still holds the old bytes") + require.NotNil(t, liveEntry(p, path)) + }) + + t.Run("the entry retires once the store holds its bytes", func(t *testing.T) { + require.NoError(t, f.idx.IndexFile(path)) + require.Equal(t, 1, p.retire(f.g)) + require.Nil(t, liveEntry(p, path)) + require.Zero(t, p.divergence.Load()) + }) + + t.Run("a retired entry that disagrees with the store counts as divergence", func(t *testing.T) { + e := f.edit("a.go", "package p\n\nfunc Newest() {}\n") + require.NoError(t, f.idx.IndexFile(path)) + e.layer.AddNode(e.graphPath, &graph.Node{ID: "a.go::Phantom", Kind: graph.KindFunction, FilePath: "a.go"}) + require.NoError(t, p.publish(e)) + require.Equal(t, 1, p.retire(f.g)) + require.EqualValues(t, 1, p.divergence.Load()) + }) + + t.Run("a tombstone hides the file's symbols until the store drops the file", func(t *testing.T) { + require.NoError(t, os.Remove(path)) + f.seq++ + tomb := newProjectionTombstone(path, "a.go", "", f.seq) + require.NoError(t, p.publish(tomb)) + e, err := p.lookup(path) + require.NoError(t, err) + view := graph.NewOverlaidView(f.g, e.layer) + require.NotEmpty(t, f.g.GetFileNodes("a.go")) + require.Empty(t, view.GetFileNodes("a.go")) + require.Empty(t, view.FindNodesByName("Newest")) + require.Nil(t, view.GetNode("a.go::Newest")) + + require.Zero(t, p.retire(f.g)) + require.NoError(t, f.g.DeleteFileMetasByFiles("", []string{"a.go"})) + f.g.EvictFile("a.go") + require.Equal(t, 1, p.retire(f.g)) + }) + + t.Run("a case variant of a live entry is refused", func(t *testing.T) { + prev := pathkey.CaseInsensitivePaths + pathkey.CaseInsensitivePaths = true + t.Cleanup(func() { pathkey.CaseInsensitivePaths = prev }) + live := f.edit("b.go", "package p\n\nfunc B() {}\n") + require.NoError(t, p.publish(live)) + variant := newProjectionEntry(filepath.Join(f.dir, "B.go"), "", &indexer.Projection{GraphPath: "B.go"}, nil, f.seq+1, diskStamp{}) + require.ErrorIs(t, p.publish(variant), projectionPathCase) + require.Same(t, live, liveEntry(p, filepath.Join(f.dir, "b.go"))) + }) + + health := pendingProjectionHealth(p) + require.Equal(t, 1, health["files"]) + require.EqualValues(t, 3, health["retired"]) + require.EqualValues(t, 1, health["projection_divergence"]) + require.Equal(t, map[string]int64{"superseded": 1, "path_case": 1}, health["refused"]) +} + +func TestPendingProjectionLookupChecksDisk(t *testing.T) { + f := newProjectionFixture(t) + path := filepath.Join(f.dir, "a.go") + const content = "package p\n\nfunc Edited() {}\n" + sameSize := "package p\n\nfunc Changed() {}\n"[:len(content)] + + for _, tc := range []struct { + name string + suspect bool + change func(t *testing.T, mtime time.Time) + served bool + }{ + {name: "unchanged", served: true}, + {name: "unchanged but suspect", suspect: true, served: true}, + {name: "size", change: func(t *testing.T, _ time.Time) { + require.NoError(t, os.WriteFile(path, []byte(content+"// more\n"), 0o644)) + }}, + {name: "mtime", change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.Chtimes(path, mtime, mtime.Add(time.Second))) + }}, + {name: "inode", change: func(t *testing.T, mtime time.Time) { + replacement := path + ".tmp" + require.NoError(t, os.WriteFile(replacement, []byte(content), 0o644)) + require.NoError(t, os.Chtimes(replacement, mtime, mtime)) + require.NoError(t, os.Rename(replacement, path)) + }}, + {name: "same stat, other bytes, after a watcher event", suspect: true, change: func(t *testing.T, mtime time.Time) { + require.NoError(t, os.WriteFile(path, []byte(sameSize), 0o644)) + require.NoError(t, os.Chtimes(path, mtime, mtime)) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newPendingProjections() + e := f.edit("a.go", content) + require.NoError(t, p.publish(e)) + if tc.change != nil { + tc.change(t, e.disk.info.ModTime()) + } + if tc.suspect { + p.markSuspect(path) + } + got, err := p.lookup(path) + if tc.served { + require.NoError(t, err) + require.Same(t, e, got) + return + } + require.ErrorIs(t, err, errProjectionExternalWrite) + require.Nil(t, got) + require.Nil(t, liveEntry(p, path), "a stale entry must not be served again") + require.EqualValues(t, 1, p.externalWrites.Load()) + }) + } +} + +func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { + node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} + projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} + return newProjectionEntry(path, "", projection, raw, seq, stamp) +} + +func TestPendingProjectionCapsRefuseWithoutEviction(t *testing.T) { + dir := t.TempDir() + p := newPendingProjections() + for i := range projectionMaxFiles { + name := fmt.Sprintf("f%03d.go", i) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, name), name, "h", 1, diskStamp{}, nil))) + } + over := syntheticProjectionEntry(filepath.Join(dir, "over.go"), "over.go", "h", 1, diskStamp{}, nil) + require.ErrorIs(t, p.publish(over), projectionOverFiles) + require.NoError(t, p.publish(syntheticProjectionEntry(filepath.Join(dir, "f000.go"), "f000.go", "h", 2, diskStamp{}, nil)), + "replacing a live file at the cap is not growth") + + huge := syntheticProjectionEntry(filepath.Join(dir, "f001.go"), "f001.go", "h", 2, diskStamp{}, nil) + huge.size = projectionMaxBytes + require.ErrorIs(t, p.publish(huge), projectionOverBytes) + + snap := p.snap.Load() + require.Len(t, snap.entries, projectionMaxFiles) + require.NotSame(t, huge, liveEntry(p, filepath.Join(dir, "f001.go"))) + require.Nil(t, liveEntry(p, filepath.Join(dir, "over.go"))) +} + +func TestPendingProjectionKillSwitch(t *testing.T) { + t.Setenv("GORTEX_PROJECTION", "0") + p := newPendingProjections() + path := filepath.Join(t.TempDir(), "a.go") + require.ErrorIs(t, p.publish(syntheticProjectionEntry(path, "a.go", "h", 1, diskStamp{}, nil)), projectionDisabled) + require.Empty(t, p.snap.Load().entries) + e, err := p.lookup(path) + require.NoError(t, err) + require.Nil(t, e) +} + +// storeTripwire forwards to a real store until it is cleared; any store call +// after that panics. +type storeTripwire struct{ graph.Store } + +func TestProjectFileReadsNoStore(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.go") + content := []byte("package p\n\nimport \"fmt\"\n\nfunc F() { fmt.Println(G()) }\n\nfunc G() int { return 1 }\n") + require.NoError(t, os.WriteFile(path, content, 0o644)) + store := &storeTripwire{Store: graph.New()} + idx := newProjectionTestIndexer(t, store) + idx.SetRootPath(dir) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + p := newPendingProjections() + + backing := store.Store + store.Store = nil + func() { + defer func() { + store.Store = backing + if r := recover(); r != nil { + t.Fatalf("store accessed while building or publishing a projection: %v", r) + } + }() + e, err := projectFile(context.Background(), idx, path, content, 1, stamp) + require.NoError(t, err) + require.NoError(t, p.publish(e)) + got, err := p.lookup(path) + require.NoError(t, err) + require.Same(t, e, got) + require.NotEmpty(t, got.layer.NodesByName("G")) + }() +} + +// 64 goroutines publish, retire, mark and read at once. Readers never see a +// torn snapshot, and retirement never drops an entry a newer publish put in +// place: every file ends with its newest entry. +func TestPendingProjectionConcurrentPublishRetireRead(t *testing.T) { + const ( + files = 16 + publishers = 32 + rounds = 50 + retirers = 16 + readers = 16 + ) + dir := t.TempDir() + store := graph.New() + paths := make([]string, files) + stamps := make([]diskStamp, files) + raw := []byte("package p\n") + for i := range files { + name := fmt.Sprintf("f%02d.go", i) + paths[i] = filepath.Join(dir, name) + require.NoError(t, os.WriteFile(paths[i], raw, 0o644)) + stamp, err := statDiskStamp(paths[i]) + require.NoError(t, err) + stamps[i] = stamp + store.AddNode(&graph.Node{ID: name, Kind: graph.KindFile, FilePath: name, StartLine: 1, EndLine: 1}) + require.NoError(t, store.SetFileMetas("", []graph.FileMetaRow{{FilePath: name, ContentHash: "stale"}})) + } + + p := newPendingProjections() + var seq atomic.Uint64 + last := make([]atomic.Uint64, publishers) + var publishing, background sync.WaitGroup + done := make(chan struct{}) + for w := range publishers { + publishing.Add(1) + go func() { + defer publishing.Done() + file := w % files + for r := range rounds { + s := seq.Add(1) + // Only the last round's bytes are newer than the store's, so + // that entry must survive retirement. + hash := "stale" + if r == rounds-1 { + hash = "fresh" + last[w].Store(s) + } + e := syntheticProjectionEntry(paths[file], filepath.Base(paths[file]), hash, s, stamps[file], raw) + if err := p.publish(e); err != nil && !errors.Is(err, projectionSuperseded) { + t.Errorf("publish: %v", err) + } + } + }() + } + for range retirers { + background.Add(1) + go func() { + defer background.Done() + for { + select { + case <-done: + return + default: + p.retire(store) + } + } + }() + } + for r := range readers { + background.Add(1) + go func() { + defer background.Done() + for i := 0; ; i++ { + select { + case <-done: + return + default: + } + path := paths[(r+i)%files] + if i%3 == 0 { + p.markSuspect(path) + } + e, err := p.lookup(path) + if err != nil { + t.Errorf("lookup %s: %v", path, err) + return + } + if e != nil && (e.key != pathkey.MutationKey(path) || len(e.layer.FileNodes(e.graphPath)) != 1) { + t.Errorf("lookup %s served a torn entry", path) + return + } + snap := p.snap.Load() + var bytes int64 + for _, e := range snap.entries { + bytes += e.size + } + if bytes != snap.bytes { + t.Errorf("snapshot bytes %d, entries sum to %d", snap.bytes, bytes) + return + } + } + }() + } + publishing.Wait() + close(done) + background.Wait() + p.retire(store) + + for file, path := range paths { + var newest uint64 + for w := range publishers { + if w%files == file { + newest = max(newest, last[w].Load()) + } + } + e := liveEntry(p, path) + require.NotNil(t, e, "entry for %s was lost", path) + require.Equal(t, newest, e.commitSeq, path) + require.Equal(t, "fresh", e.hash, path) + } +} diff --git a/internal/mcp/source_projection_fixtures_test.go b/internal/mcp/source_projection_fixtures_test.go deleted file mode 100644 index 04cc472d5..000000000 --- a/internal/mcp/source_projection_fixtures_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package mcp - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/zzet/gortex/internal/config" - "github.com/zzet/gortex/internal/graph" - "github.com/zzet/gortex/internal/indexer" - "github.com/zzet/gortex/internal/parser" - "github.com/zzet/gortex/internal/parser/languages" - "github.com/zzet/gortex/internal/pathkey" -) - -type projectionFixture struct { - t *testing.T - dir string - g *graph.Graph - idx *indexer.Indexer - seq uint64 -} - -// newProjectionFixture indexes a one-file Go repository into an in-memory -// store, the state before a tool edit. -func newProjectionFixture(t *testing.T) *projectionFixture { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package p\n\nfunc Old() {}\n"), 0o644)) - g := graph.New() - idx := newProjectionTestIndexer(t, g) - _, err := idx.Index(dir) - require.NoError(t, err) - return &projectionFixture{t: t, dir: dir, g: g, idx: idx} -} - -func newProjectionTestIndexer(t testing.TB, g graph.Store) *indexer.Indexer { - t.Helper() - registry := parser.NewRegistry() - languages.RegisterAll(registry) - cfg := config.Default().Index - cfg.Workers = 1 - idx := indexer.New(g, registry, cfg, zap.NewNop()) - t.Cleanup(idx.Close) - return idx -} - -// edit writes content to rel as a tool would and projects it. -func (f *projectionFixture) edit(rel, content string) *projectionEntry { - f.t.Helper() - path := filepath.Join(f.dir, rel) - require.NoError(f.t, os.WriteFile(path, []byte(content), 0o644)) - stamp, err := statDiskStamp(path) - require.NoError(f.t, err) - f.seq++ - e, err := projectFile(context.Background(), f.idx, path, []byte(content), f.seq, stamp) - require.NoError(f.t, err) - return e -} - -func liveEntry(p *pendingProjections, path string) *projectionEntry { - return p.snap.Load().entries[pathkey.MutationKey(path)] -} - -type projectionRetirementSpy struct { - *graph.Graph - metadataReads int - nodeReads int -} - -func (s *projectionRetirementSpy) FileMetasByPaths(prefix string, paths []string) (map[string]graph.FileMetaRow, error) { - s.metadataReads++ - return s.Graph.FileMetasByPaths(prefix, paths) -} - -func (s *projectionRetirementSpy) GetFileNodes(path string) []*graph.Node { - s.nodeReads++ - return s.Graph.GetFileNodes(path) -} - -func syntheticProjectionEntry(path, graphPath, hash string, seq uint64, stamp diskStamp, raw []byte) *projectionEntry { - node := &graph.Node{ID: graphPath, Kind: graph.KindFile, FilePath: graphPath, StartLine: 1, EndLine: 1} - projection := &indexer.Projection{GraphPath: graphPath, Language: "go", ContentHash: hash, Nodes: []*graph.Node{node}} - return newProjectionEntry(path, "", projection, raw, seq, stamp) -} From e9107290b0e538e16b3bce1435cd245e7650f4fe Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:06:42 +0100 Subject: [PATCH 113/138] test(mcp): distinguish policy refusal and per-admission identity --- internal/mcp/dispatch_slot_release_test.go | 7 +++++++ .../mcp/edit_acknowledgement_envelope_test.go | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/mcp/dispatch_slot_release_test.go b/internal/mcp/dispatch_slot_release_test.go index 37d501f8a..d744c6d2d 100644 --- a/internal/mcp/dispatch_slot_release_test.go +++ b/internal/mcp/dispatch_slot_release_test.go @@ -128,6 +128,13 @@ func TestMutationReindexStateReturnsTheDispatchSlotOnceItOnlyWaits(t *testing.T) require.NotEmpty(t, outcomes[1].Receipt) outcomes[0].Receipt, outcomes[1].Receipt = "", "" } + // These are independent admissions on separate servers. Their private + // publication identities must survive even when full ACK omits the + // public pending receipt; compare the answer after checking identity. + require.NotEmpty(t, outcomes[0].publicationReceipt) + require.NotEmpty(t, outcomes[1].publicationReceipt) + require.NotEqual(t, outcomes[0].publicationReceipt, outcomes[1].publicationReceipt) + outcomes[0].publicationReceipt, outcomes[1].publicationReceipt = "", "" require.Equal(t, outcomes[1], outcomes[0], "giving the slot back leaves the edit's answer unchanged") }) } diff --git a/internal/mcp/edit_acknowledgement_envelope_test.go b/internal/mcp/edit_acknowledgement_envelope_test.go index 977dbd1c9..5d92d3b14 100644 --- a/internal/mcp/edit_acknowledgement_envelope_test.go +++ b/internal/mcp/edit_acknowledgement_envelope_test.go @@ -123,14 +123,14 @@ func TestAcknowledgementEnvelopeActualPreLeafRefusal(t *testing.T) { s.ToolCallTimeout = -1 var a *editAcknowledgement wrapped := s.wrapToolHandler(func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { - t.Error("invalid freshness input must refuse before the leaf") + t.Error("invalid view input must refuse before the leaf") return mcp.NewToolResultText("unexpected"), nil }) capture := s.boundToolHandler(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { a = editAcknowledgementFrom(ctx, s) return wrapped(ctx, req) }) - result, err := capture(t.Context(), writeFileRequest(map[string]any{"wait_deadline": "invalid-duration"})) + result, err := capture(t.Context(), writeFileRequest(map[string]any{"view": map[string]any{"kind": "invalid-view"}})) require.NoError(t, err) require.True(t, result.IsError) snapshot := a.snapshot() @@ -223,3 +223,20 @@ func TestAcknowledgementEnvelopeUnobservedReadsHaveNoIdentityOrAllocation(t *tes require.Zero(t, allocations, "nil observer must not allocate an envelope/token/closure") require.Equal(t, before, editAcknowledgementSequence.Load()) } + +// ACK policy captures freshness before the measured handler. Invalid policy +// input must stop there, while a later middleware refusal is measured above. +func TestAcknowledgementFreshnessPolicyRefusesBeforeMeasuredHandler(t *testing.T) { + s := fastPathTestServer(t) + s.ToolCallTimeout = -1 + entered := false + handler := s.boundToolHandler(func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + entered = true + return mcp.NewToolResultText("unexpected"), nil + }) + result, err := handler(t.Context(), writeFileRequest(map[string]any{"wait_deadline": "invalid-duration"})) + require.NoError(t, err) + require.True(t, result.IsError) + require.False(t, entered, "malformed policy must not invoke any mutation handler") + require.Contains(t, result.Content[0].(mcp.TextContent).Text, "wait_deadline") +} From f6c6f315876db9883cfe31e5b65180044ef066f2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:11:23 +0100 Subject: [PATCH 114/138] test(indexer): exercise late proof after bounded refresh exhaustion --- .../indexer/build_content_proof_reads_test.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/indexer/build_content_proof_reads_test.go b/internal/indexer/build_content_proof_reads_test.go index d93493d3d..94e37b837 100644 --- a/internal/indexer/build_content_proof_reads_test.go +++ b/internal/indexer/build_content_proof_reads_test.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "testing" "time" @@ -616,6 +617,9 @@ func TestTheDeletionWalkRecordsTheFilesItReadsAgain(t *testing.T) { func TestAFileReadAgainAfterItsParseWentStaleIsRecorded(t *testing.T) { store := builderOpenStore(t, "base") repoDir := sideInputCheckout(t, store) + // Keep this late-fence control to the moved Go file; the generic fixture + // also offers an independent JavaScript edit that would stage normally. + builderWriteFile(t, repoDir, "web/app.js", sideInputFiles["web/app.js"]) sampler, err := gitstate.NewDirtySampler(repoDir, "", "") if err != nil { t.Fatal(err) @@ -633,10 +637,18 @@ func TestAFileReadAgainAfterItsParseWentStaleIsRecorded(t *testing.T) { t.Fatal("no Go extractor registered") } saver := &savingExtractor{Extractor: goExtractor, trigger: "a.go", path: filepath.Join(repoDir, "pkg", "a.go"), line: "\nfunc Later() {}\n"} + // The first extraction saves B. The bounded fresh extraction then saves + // C, exhausting that preparation so a stable legacy read reaches the + // late content-proof fence rather than the staged receipt's early refusal. + var extractions atomic.Int32 + saver.early = "a.go" + saver.earlyEvery = func() bool { return extractions.Add(1) == 2 } saver.armed.Store(true) registry.Register(saver) refuted := readSetConfirmations.refuted.Load() + core, logs := observer.New(zap.InfoLevel) builder := builderNewBuilder(store) + builder.Logger = zap.New(core) builder.Registry = registry builder.SetContractCoreRuntime(ContractCoreRuntimeHooks{}) generationID, _, err := builder.BuildDirtyLayer(context.Background(), DirtyLayerRequest{ @@ -656,6 +668,28 @@ func TestAFileReadAgainAfterItsParseWentStaleIsRecorded(t *testing.T) { if _, saveErr := saver.saved(); saver.armed.Load() || saveErr != nil { t.Fatalf("the save never landed (err %v)", saveErr) } + if saver.earlySaves.Load() != 1 { + t.Fatal("the second real save did not exhaust the fresh preparation") + } + parses, fallbacks := 0, 0 + for _, entry := range logs.FilterMessage("indexer: reconcile phase complete").All() { + fields := entry.ContextMap() + switch fields["phase"] { + case "chunk_parse": + parses++ + if fields["fallback_files"] != int64(1) || fields["staged_files"] != int64(0) { + t.Fatalf("the moved file unexpectedly staged: %v", fields) + } + case "chunk_fallback_parse_apply": + if fields["files"] != int64(1) || fields["failed_files"] != int64(0) { + t.Fatalf("legacy parse/apply did not complete: %v", fields) + } + fallbacks++ + } + } + if parses != 1 || fallbacks != 1 { + t.Fatalf("parse/legacy completion counts = %d/%d, want 1/1", parses, fallbacks) + } if !errors.Is(err, ErrDirtySnapshotChanged) || readSetConfirmations.refuted.Load() == refuted { published := false if row, found, _ := store.Catalog().GetViewGeneration(context.Background(), generationID); found && row.PublishedAt != 0 { From ae28df0117ba56343fcda4ba879d86d14b6b3511 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:17:06 +0100 Subject: [PATCH 115/138] test(mcp): attribute actual committed projection parsing in symbol edits --- internal/mcp/edit_symbol_measurement_contract_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/mcp/edit_symbol_measurement_contract_test.go b/internal/mcp/edit_symbol_measurement_contract_test.go index 0eac772f8..6ebd5caf3 100644 --- a/internal/mcp/edit_symbol_measurement_contract_test.go +++ b/internal/mcp/edit_symbol_measurement_contract_test.go @@ -98,11 +98,15 @@ func TestEditSymbolMeasurementFixtureUsesActualSpliceAndExplicitAbsence(t *testi status := s.mutationStatusPayload(record) require.Equal(t, "edit_symbol", status["tool"]) ack := status["acknowledgement"].(acknowledgementPayload) - require.Equal(t, "not_applicable_text_splice", ack.ParserScope) - require.Equal(t, "not_applicable", ack.PhaseStates["parse"]) + // Text splicing itself has no parser. The same source operation + // now builds its committed file projection, whose actual parse + // belongs to this ACK rather than a fabricated absent phase. + require.Equal(t, "mixed_observed_parser_paths", ack.ParserScope) + require.Equal(t, "measured", ack.PhaseStates["parse"]) parse, present := ack.ObservedExecutionMS["parse"] require.True(t, present) - require.Nil(t, parse) + require.NotNil(t, parse) + require.Positive(t, *parse) actual, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, strings.Replace(source, declaration, replacement, 1), string(actual)) From 455c615f2a36f97972e23d52cccbf13a3b7105a5 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:28:02 +0100 Subject: [PATCH 116/138] test(mcp): count relocation and committed projection parse evidence --- internal/mcp/symbol_write_runtime_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/mcp/symbol_write_runtime_test.go b/internal/mcp/symbol_write_runtime_test.go index d4ff995bc..2a3802e93 100644 --- a/internal/mcp/symbol_write_runtime_test.go +++ b/internal/mcp/symbol_write_runtime_test.go @@ -32,8 +32,9 @@ func TestSymbolWritersUseCurrentDeclarationAfterLineShift(t *testing.T) { ack.finishReply(result, callErr) ack.finishActual() observed := ack.snapshot() - require.EqualValues(t, 1, observed.ParseGate.Started, "actual source relocation extraction must be observed") - require.EqualValues(t, 1, observed.ParseGate.Completed) + require.EqualValues(t, 2, observed.ParseGate.Started, "actual relocation extraction and committed projection each own one observed parse") + require.EqualValues(t, 2, observed.ParseGate.Completed) + require.Equal(t, "mixed_observed_parser_paths", observed.ParserScope) require.True(t, observed.LapsDisjoint) } else { result, callErr = srv.handleSafeDeleteSymbol(t.Context(), req) From 4f4ad01302e8c29afba89e67e16a7d4f24cc7133 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:32:54 +0100 Subject: [PATCH 117/138] fix(mcp): preserve actual publication failure during freshness refresh --- internal/mcp/edit_serialization.go | 6 ++++++ internal/mcp/edit_serialization_test.go | 18 ++++++++++++++++++ .../mcp/worktree_batch_integration_test.go | 6 +++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index ca81c376a..885b981c1 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -916,6 +916,12 @@ func attachMutationFreshnessFields(resp map[string]any, outcome mutationReindexO // reads next to disk_status (mutation_commit.go) rather than having to be // inferred from the reindexed / reindex_pending / reindex_error triple. resp["graph_status"] = graphStatusFor(outcome) + if outcome.Err != nil { + resp["reindex_error"] = outcome.Err.Error() + } + if outcome.Pending { + resp["reindex_pending"] = true + } attachMutationTierState(resp, outcome.tiers) attachDerivedFanout(resp, outcome.DerivedFanout) if outcome.Generation > 0 { diff --git a/internal/mcp/edit_serialization_test.go b/internal/mcp/edit_serialization_test.go index 1956839f3..b798ad8cc 100644 --- a/internal/mcp/edit_serialization_test.go +++ b/internal/mcp/edit_serialization_test.go @@ -371,3 +371,21 @@ func TestAtomicBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { t.Fatalf("stale-range fallback result:\n%s\nwant:\n%s", got, want) } } + +func TestMutationFreshnessFieldsKeepActualFailureAndClearPreviousState(t *testing.T) { + resp := map[string]any{"reindex_error": "old failure", "reindex_pending": true, "reindex_receipt": "old", "syntax_health": "old"} + attachMutationFreshnessFields(resp, mutationReindexOutcome{Err: errors.New("current publication failure")}) + require.Equal(t, "current publication failure", resp["reindex_error"]) + require.Equal(t, mutationGraphFailed, resp["graph_status"]) + require.NotContains(t, resp, "reindex_pending") + require.NotContains(t, resp, "reindex_receipt") + require.NotContains(t, resp, "syntax_health") + attachMutationFreshnessFields(resp, mutationReindexOutcome{Pending: true, Receipt: "current", Generation: 2}) + require.NotContains(t, resp, "reindex_error") + require.Equal(t, true, resp["reindex_pending"]) + require.Equal(t, "current", resp["reindex_receipt"]) + attachMutationFreshnessFields(resp, mutationReindexOutcome{Reindexed: true, Generation: 2, AppliedGeneration: 2}) + require.NotContains(t, resp, "reindex_error") + require.NotContains(t, resp, "reindex_pending") + require.Equal(t, mutationGraphFresh, resp["graph_status"]) +} diff --git a/internal/mcp/worktree_batch_integration_test.go b/internal/mcp/worktree_batch_integration_test.go index 0c998795e..4a89737bb 100644 --- a/internal/mcp/worktree_batch_integration_test.go +++ b/internal/mcp/worktree_batch_integration_test.go @@ -165,7 +165,11 @@ func TestWorktreeBatchFacadeGuardedTwoFileEdit(t *testing.T) { require.Equal(t, "observed", ack.State) require.NotNil(t, ack.Snapshot) require.EqualValues(t, 3, ack.Snapshot.DiskWrite.Completed, "two physical files and one forward directory-sync envelope") - require.Equal(t, "not_applicable", ack.Snapshot.ParseGate.State) + require.Equal(t, "measured", ack.Snapshot.ParseGate.State, "the committed cohort now builds actual file projections") + require.EqualValues(t, 1, ack.Snapshot.ParseGate.Started, "parallel file parses share one actual ACK wall interval") + require.EqualValues(t, 1, ack.Snapshot.ParseGate.Completed) + require.NotNil(t, ack.Snapshot.ParseGate.ObservedMS) + require.Positive(t, *ack.Snapshot.ParseGate.ObservedMS) if root == f.worktree { require.EqualValues(t, 1, ack.Snapshot.TicketAdmission.Completed, "one actual checkout batch admission, not one per file") } From 101ec6d89ca1c55140e58f9fa5c26ae665ff3b8e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:33:09 +0100 Subject: [PATCH 118/138] test(mcp): import freshness state assertion helper --- internal/mcp/edit_serialization_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/mcp/edit_serialization_test.go b/internal/mcp/edit_serialization_test.go index b798ad8cc..4f5deef09 100644 --- a/internal/mcp/edit_serialization_test.go +++ b/internal/mcp/edit_serialization_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/pathkey" From da0c0ed0f217217c2c960e653d9993a9fd9363ec Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:35:52 +0100 Subject: [PATCH 119/138] test(mcp): isolate the committed keyed response handoff window --- internal/mcp/keyed_response_wait_test.go | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 internal/mcp/keyed_response_wait_test.go diff --git a/internal/mcp/keyed_response_wait_test.go b/internal/mcp/keyed_response_wait_test.go new file mode 100644 index 000000000..6f870ac49 --- /dev/null +++ b/internal/mcp/keyed_response_wait_test.go @@ -0,0 +1,114 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" +) + +type keyedReplyResult struct { + result *mcplib.CallToolResult + err error +} + +// Keep the original graph wait open after the real physical commit. The +// duplicate must release its acquired path before waiting for that response. +func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "keyed.txt") + require.NoError(t, os.WriteFile(path, []byte("x\n"), 0600)) + s := newMutationServer() + publication := make(chan indexer.MutationResult, 1) + var scheduled atomic.Int64 + s.SetWatcher(mutationTestWatcher{done: publication, generation: 1, scheduled: &scheduled}) + s.mutationReindexWait = time.Minute // an owned signal controls completion + complete := sync.OnceFunc(func() { + publication <- indexer.MutationResult{Reindexed: true, RequestedGeneration: 1, AppliedGeneration: 1} + close(publication) + }) + t.Cleanup(complete) + args := map[string]any{"path": path, "old_string": "x", "new_string": "x\ny", "mutation_id": "once"} + original := make(chan keyedReplyResult, 1) + go func() { + r, err := s.handleEditFile(t.Context(), editFileRequest(args)) + original <- keyedReplyResult{r, err} + }() + require.Eventually(t, func() bool { return scheduled.Load() == 1 }, 3*time.Second, time.Millisecond) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + _, releasePath, err := acquireMutationPath(ctx, path) + require.NoError(t, err, "the original graph wait must release its physical path") + defer releasePath() + record, ok := s.mutationCommits.byMutationID("once") + require.True(t, ok) + record.mu.Lock() + releaseRecord := sync.OnceFunc(record.mu.Unlock) + defer releaseRecord() + require.Equal(t, mutationDiskCommitted, record.disk) + require.Nil(t, record.response, "the original result is deliberately not ready") + duplicate := make(chan keyedReplyResult, 1) + go func() { r, err := s.handleEditFile(ctx, editFileRequest(args)); duplicate <- keyedReplyResult{r, err} }() + releasePath() + key := pathkey.MutationKey(path) + require.Eventually(t, func() bool { + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + entry := mutationPathLocks.byPath[key] + return entry != nil && entry.refs == 1 && len(entry.token) == 0 + }, time.Second, time.Millisecond, "the duplicate must own the path and wait at the held record") + probe := make(chan error, 1) + go func() { + _, release, err := acquireMutationPath(ctx, path) + if err != nil { + probe <- err + return + } + complete() // only after the duplicate returned or released its path + release() + probe <- nil + }() + require.Eventually(t, func() bool { + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + entry := mutationPathLocks.byPath[key] + return entry != nil && entry.refs == 2 + }, time.Second, time.Millisecond, "the independent path probe must queue behind the duplicate") + releaseRecord() + select { + case err := <-probe: + require.NoError(t, err, "a replay wait must not retain the path") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + var repeated, first keyedReplyResult + select { + case repeated = <-duplicate: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + select { + case first = <-original: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + require.NoError(t, first.err) + require.NoError(t, repeated.err) + require.False(t, first.result.IsError, "%s", resultText(first.result)) + require.False(t, repeated.result.IsError, "%s", resultText(repeated.result)) + firstBody, repeatedBody := decodeFileOpsResult(t, first.result), decodeFileOpsResult(t, repeated.result) + require.Equal(t, true, repeatedBody["replayed"]) + require.Equal(t, firstBody["mutation_receipt"], repeatedBody["mutation_receipt"]) + require.Equal(t, firstBody["new_sha"], repeatedBody["new_sha"]) + require.Equal(t, int64(1), scheduled.Load(), "a replay cannot enqueue another publication") + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "x\ny\n", string(raw), "the physical edit occurs once") +} From 52af36e1a05d03243f7e4bb2c4c53c67f95877ae Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:39:18 +0100 Subject: [PATCH 120/138] fix(mcp): wait for keyed mutation replies after releasing the source path --- internal/mcp/keyed_response_wait_test.go | 80 ++++++++++++++++++++-- internal/mcp/mutation_commit.go | 13 ++++ internal/mcp/mutation_replay_wait.go | 86 ++++++++++++++++++++++++ internal/mcp/tools_coding.go | 3 +- internal/mcp/tools_fileops.go | 6 +- 5 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 internal/mcp/mutation_replay_wait.go diff --git a/internal/mcp/keyed_response_wait_test.go b/internal/mcp/keyed_response_wait_test.go index 6f870ac49..159a5ebff 100644 --- a/internal/mcp/keyed_response_wait_test.go +++ b/internal/mcp/keyed_response_wait_test.go @@ -23,6 +23,12 @@ type keyedReplyResult struct { // Keep the original graph wait open after the real physical commit. The // duplicate must release its acquired path before waiting for that response. func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { + for _, mode := range []string{"published", "cancelled waiter", "evicted receipt"} { + t.Run(mode, func(t *testing.T) { keyedReplayResponseHandoff(t, mode) }) + } +} + +func keyedReplayResponseHandoff(t *testing.T, mode string) { path := filepath.Join(t.TempDir(), "keyed.txt") require.NoError(t, os.WriteFile(path, []byte("x\n"), 0600)) s := newMutationServer() @@ -44,6 +50,8 @@ func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { require.Eventually(t, func() bool { return scheduled.Load() == 1 }, 3*time.Second, time.Millisecond) ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() + replayCtx, cancelReplay := context.WithCancel(ctx) + defer cancelReplay() _, releasePath, err := acquireMutationPath(ctx, path) require.NoError(t, err, "the original graph wait must release its physical path") defer releasePath() @@ -55,7 +63,10 @@ func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { require.Equal(t, mutationDiskCommitted, record.disk) require.Nil(t, record.response, "the original result is deliberately not ready") duplicate := make(chan keyedReplyResult, 1) - go func() { r, err := s.handleEditFile(ctx, editFileRequest(args)); duplicate <- keyedReplyResult{r, err} }() + go func() { + r, err := s.handleEditFile(replayCtx, editFileRequest(args)) + duplicate <- keyedReplyResult{r, err} + }() releasePath() key := pathkey.MutationKey(path) require.Eventually(t, func() bool { @@ -71,7 +82,17 @@ func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { probe <- err return } - complete() // only after the duplicate returned or released its path + // The independent probe can act only after the duplicate returned or + // released its path. Cancellation never targets the original caller. + switch mode { + case "cancelled waiter": + cancelReplay() + case "evicted receipt": + s.mutationCommits.mu.Lock() + s.mutationCommits.evictLocked(time.Now().Add(mutationCommitRetention + time.Second)) + s.mutationCommits.mu.Unlock() + } + complete() release() probe <- nil }() @@ -102,13 +123,58 @@ func TestKeyedReplayWaitsForOriginalResponseOutsidePath(t *testing.T) { require.NoError(t, first.err) require.NoError(t, repeated.err) require.False(t, first.result.IsError, "%s", resultText(first.result)) - require.False(t, repeated.result.IsError, "%s", resultText(repeated.result)) - firstBody, repeatedBody := decodeFileOpsResult(t, first.result), decodeFileOpsResult(t, repeated.result) - require.Equal(t, true, repeatedBody["replayed"]) - require.Equal(t, firstBody["mutation_receipt"], repeatedBody["mutation_receipt"]) - require.Equal(t, firstBody["new_sha"], repeatedBody["new_sha"]) + firstBody := decodeFileOpsResult(t, first.result) + require.Equal(t, true, firstBody["reindexed"], "a duplicate's cancellation/eviction cannot cancel original publication") + if mode == "published" { + require.False(t, repeated.result.IsError, "%s", resultText(repeated.result)) + repeatedBody := decodeFileOpsResult(t, repeated.result) + require.Equal(t, true, repeatedBody["replayed"]) + require.Equal(t, firstBody["mutation_receipt"], repeatedBody["mutation_receipt"]) + require.Equal(t, firstBody["new_sha"], repeatedBody["new_sha"]) + } else { + require.True(t, repeated.result.IsError) + require.Contains(t, resultText(repeated.result), "already committed") + require.Contains(t, resultText(repeated.result), record.id) + } require.Equal(t, int64(1), scheduled.Load(), "a replay cannot enqueue another publication") raw, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, "x\ny\n", string(raw), "the physical edit occurs once") } + +type unavailableKeyedReplyWatcher struct { + mutationTestWatcher + calls atomic.Int64 +} + +func (w *unavailableKeyedReplyWatcher) EnqueueToolMutation(context.Context, string) (*indexer.MutationTicket, error) { + w.calls.Add(1) + panic("owned publication failure after physical commit") +} + +func TestKeyedReplayTerminalUnavailableNeverWritesAgain(t *testing.T) { + path := filepath.Join(t.TempDir(), "terminal.txt") + require.NoError(t, os.WriteFile(path, []byte("x\n"), 0600)) + s := newMutationServer() + w := &unavailableKeyedReplyWatcher{} + s.SetWatcher(w) + args := map[string]any{"path": path, "old_string": "x", "new_string": "x\ny", "mutation_id": "terminal"} + var recovered any + func() { + defer func() { recovered = recover() }() + _, _ = s.handleEditFile(t.Context(), editFileRequest(args)) + }() + require.Equal(t, "owned publication failure after physical commit", recovered) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result, err := s.handleEditFile(ctx, editFileRequest(args)) + require.NoError(t, err) + require.True(t, result.IsError) + require.Contains(t, resultText(result), "already committed") + require.Contains(t, resultText(result), "original handler finished") + require.NotContains(t, resultText(result), "deadline") + require.EqualValues(t, 1, w.calls.Load()) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "x\ny\n", string(raw)) +} diff --git a/internal/mcp/mutation_commit.go b/internal/mcp/mutation_commit.go index 19dfc808c..05a388af4 100644 --- a/internal/mcp/mutation_commit.go +++ b/internal/mcp/mutation_commit.go @@ -182,6 +182,9 @@ type mutationCommitRecord struct { // carry a caller-chosen mutation_id. Without a key there is nothing to // replay against, so ordinary edits pay no memory for this. response map[string]any + // Keyed retries may arrive after path release but before the first reply. + // This bounded record owns their completion signal, never another ledger. + responseReady chan struct{} } type mutationCommitSnapshot struct { @@ -462,6 +465,7 @@ func (r *mutationCommitRecord) retainResponse(resp map[string]any) { clone[key] = value } r.response = clone + r.closeResponseReadyLocked() } func (r *mutationCommitRecord) replayResponse() (map[string]any, bool) { @@ -562,6 +566,7 @@ func (l *mutationCommitLedger) evictLocked(now time.Time) { record.mu.RUnlock() if expired { record.mu.Lock() + record.closeResponseReadyLocked() record.publication = nil record.acknowledgement = nil record.readRepair = nil @@ -582,6 +587,7 @@ func (l *mutationCommitLedger) evictLocked(now time.Time) { l.ordered = l.ordered[1:] oldest.mu.Lock() l.unbindFreshnessLocked(oldest) + oldest.closeResponseReadyLocked() oldest.publication = nil oldest.acknowledgement = nil oldest.readRepair = nil @@ -746,6 +752,9 @@ func (s *Server) beginMutationCommit(ctx context.Context, tool, mutationID, fing startedAt: time.Now(), acknowledgement: editAcknowledgementFrom(ctx, s), } + if mutationID != "" { + record.responseReady = make(chan struct{}) + } if state := checkoutMutationFromContext(ctx); state != nil { record.checkoutID = state.checkoutID record.checkoutIncarnation = state.incarnation @@ -1069,6 +1078,10 @@ func (s *Server) replayMutation(mutationID, fingerprint string) (map[string]any, "mutation_id %q already committed but its response was not retained; query mutation_status for the receipt: %s", mutationID, encoded), true } + return s.renderMutationReplay(record, resp) +} + +func (s *Server) renderMutationReplay(record *mutationCommitRecord, resp map[string]any) (map[string]any, string, bool) { // The disk verdict and receipt remain historical. File-view facts are // live observations and cannot be replayed after another commit replaced // the registry entry. Refresh only those scalars on this response clone. diff --git a/internal/mcp/mutation_replay_wait.go b/internal/mcp/mutation_replay_wait.go new file mode 100644 index 000000000..28404f0e7 --- /dev/null +++ b/internal/mcp/mutation_replay_wait.go @@ -0,0 +1,86 @@ +package mcp + +import ( + "context" + "fmt" + "time" +) + +// closeResponseReadyLocked requires the record's write lock. Nil after close +// makes retained, terminal, and eviction paths share one completion signal. +func (r *mutationCommitRecord) closeResponseReadyLocked() { + if r.responseReady != nil { + close(r.responseReady) + r.responseReady = nil + } +} + +// The handler may stop after disk commit without retaining a reply (including +// panic recovery). A retry must learn that terminal unavailability too. +func (r *mutationCommitRecord) finishResponseWait() { + if !r.isPhysicalCommit() { + return + } + r.mu.Lock() + r.closeResponseReadyLocked() + r.mu.Unlock() +} + +// A matching physical commit is already final, but its original full-policy +// reply may still be waiting for publication. Release the source path before +// waiting; cancellation stops only this caller, never the original request. +func (s *Server) replayMutationAfterPathRelease(ctx context.Context, key, fingerprint string, releasePath func()) (map[string]any, string, bool) { + if key == "" { + return nil, "", false + } + record, found := s.mutationCommits.byMutationID(key) + if !found { + return nil, "", false + } + record.mu.RLock() + matchingCommitted := record.owner == s && record.key == key && record.fingerprint == fingerprint && record.disk == mutationDiskCommitted + ready := record.responseReady + pending := record.response == nil + record.mu.RUnlock() + if !matchingCommitted { + return s.replayMutation(key, fingerprint) + } + unavailable := func(reason string) (map[string]any, string, bool) { + return nil, fmt.Sprintf("mutation_id %q already committed; its original response is unavailable (%s). Do not reapply: query mutation_status for receipt %s", key, reason, record.id), true + } + if pending && ready != nil { + releasePath() + if err := ctx.Err(); err != nil { + return unavailable(err.Error()) + } + select { + case <-ctx.Done(): + return unavailable(ctx.Err().Error()) + case <-ready: + } + if err := ctx.Err(); err != nil { + return unavailable(err.Error()) + } + } + // Lock order stays ledger -> record. Copy the immutable retained result + // while the same exact owner is still registered; render live facts later. + s.mutationCommits.mu.Lock() + s.mutationCommits.evictLocked(time.Now()) + if s.mutationCommits.byKey[key] != record || s.mutationCommits.byID[record.id] != record { + s.mutationCommits.mu.Unlock() + return unavailable("receipt evicted or replaced") + } + record.mu.RLock() + if record.owner != s || record.key != key || record.fingerprint != fingerprint || record.disk != mutationDiskCommitted || record.response == nil { + record.mu.RUnlock() + s.mutationCommits.mu.Unlock() + return unavailable("original handler finished without a retained response") + } + response := make(map[string]any, len(record.response)+1) + for field, value := range record.response { + response[field] = value + } + record.mu.RUnlock() + s.mutationCommits.mu.Unlock() + return s.renderMutationReplay(record, response) +} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 9fab7c13b..9e8d4b087 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -3108,7 +3108,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) // landed old_source no longer matches, so an un-replayed retry would be // refused rather than answered with the original result. fingerprint := mutationFingerprint("edit_symbol", id, absPath, oldSource, newSource) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -3199,6 +3199,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) perm = info.Mode().Perm() } commit, writeErr := s.commitFileMutation(ctx, "edit_symbol", mutationID, fingerprint, node.FilePath, absPath, newContentBytes, perm) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("edit", commit, writeErr)), nil diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 08c18acb8..1e114af45 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -935,7 +935,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* // old_string, so an un-replayed retry would fail the match check with a // confusing "not found" instead of returning the original result. fingerprint := mutationFingerprint("edit_file", absPath, oldString, newString, strconv.FormatBool(replaceAll)) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -1053,6 +1053,7 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* perm = info.Mode().Perm() } commit, writeErr := s.commitFileMutation(ctx, "edit_file", mutationID, fingerprint, relPath, absPath, newContentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("edit", commit, writeErr)), nil @@ -1132,7 +1133,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( // call waits for it and then sees a terminal record, rather than both // deciding independently that they are the first. fingerprint := mutationFingerprint("write_file", absPath, content) - if replay, conflict, matched := s.replayMutation(mutationID, fingerprint); matched { + if replay, conflict, matched := s.replayMutationAfterPathRelease(ctx, mutationID, fingerprint, releaseMutation); matched { if conflict != "" { return mcp.NewToolResultError(conflict), nil } @@ -1229,6 +1230,7 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( } commit, writeErr := s.commitFileMutation(ctx, "write_file", mutationID, fingerprint, relPath, absPath, contentBytes, perm, sourceMutationOperation{kind: sourceWrite, projection: prepared}) + defer commit.finishResponseWait() if writeErr != nil { if errors.Is(writeErr, errMutationNotApplied) { return mcp.NewToolResultError(mutationNotAppliedMessage("write", commit, writeErr)), nil From 7ead8c51acf18eeb95434c964f99317a3cb37f4c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 04:18:46 +0100 Subject: [PATCH 121/138] test(indexer): wait for header repair owner completion --- internal/indexer/contract_stuck_repair_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/indexer/contract_stuck_repair_test.go b/internal/indexer/contract_stuck_repair_test.go index ffab989e6..40ed4c045 100644 --- a/internal/indexer/contract_stuck_repair_test.go +++ b/internal/indexer/contract_stuck_repair_test.go @@ -823,7 +823,9 @@ func TestContractHeaderRepairRetriesFailedBaselinesWithoutAnActor(t *testing.T) t.Cleanup(r.Close) require.Eventually(t, func() bool { state, found, err := job.store.ContractInputStateContext(t.Context(), "fixture", "") - return err == nil && found && state.Accepted + // Acceptance is durable before the asynchronous baseline releases its + // live-owner marker. Observe completed repair, not only its commit. + return err == nil && found && state.Accepted && !job.mi.contractBaselineActive("fixture") }, 20*time.Second, 10*time.Millisecond) want := int64(2) if beforeStart { From d144b75eb296a6c33b1bf63a184ec97b53206b49 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:25:08 +0100 Subject: [PATCH 122/138] fix(mcp): keep ACK seed on completed checkout tickets Refs #1017. --- internal/mcp/edit_serialization.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go index 61ccb8650..84718c940 100644 --- a/internal/mcp/edit_serialization.go +++ b/internal/mcp/edit_serialization.go @@ -553,7 +553,9 @@ func (s *Server) mutationWaitDuration() time.Duration { func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { if state := checkoutMutationFromContext(ctx); state != nil { outcome := s.refreshCheckoutMutation(ctx, absPath, state) - if outcome.Err == nil && outcome.Pending && outcome.Receipt != "" { + // A completed ticket is still this write's actual admission. The + // tagged legacy-wait control must not disappear with completion timing. + if outcome.Err == nil && outcome.Receipt != "" { outcome.Err = editGateCheckoutACKSeed(ctx, s) } return outcome From e6c341d8e1944423961d5a70e8a1c268f02b1aaf Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:20:12 +0100 Subject: [PATCH 123/138] test: preserve disk stamp identity across path replacement --- internal/mcp/pending_projection_stamp_test.go | 68 +++++++++++++++++++ .../mcp/pending_projection_stamp_unix_test.go | 20 ++++++ .../pending_projection_stamp_windows_test.go | 31 +++++++++ 3 files changed, 119 insertions(+) create mode 100644 internal/mcp/pending_projection_stamp_test.go create mode 100644 internal/mcp/pending_projection_stamp_unix_test.go create mode 100644 internal/mcp/pending_projection_stamp_windows_test.go diff --git a/internal/mcp/pending_projection_stamp_test.go b/internal/mcp/pending_projection_stamp_test.go new file mode 100644 index 000000000..258c91ff8 --- /dev/null +++ b/internal/mcp/pending_projection_stamp_test.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiskStampKeepsIdentityBeforeReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + const content = "package example\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + before, err := statDiskStamp(path) + require.NoError(t, err) + // Allocate the replacement before unlinking the original so its file ID + // cannot be a reuse of the original's ID. Do not compare before the rename: + // that would accidentally initialize a lazy Windows FileInfo identity. + replacement := path + ".tmp" + require.NoError(t, os.WriteFile(replacement, []byte(content), 0o600)) + mtime := before.info.ModTime() + require.NoError(t, os.Chtimes(replacement, mtime, mtime)) + require.NoError(t, os.Rename(replacement, path)) + after, err := statDiskStamp(path) + require.NoError(t, err) + require.Equal(t, before.info.Size(), after.info.Size()) + require.True(t, before.info.ModTime().Equal(after.info.ModTime())) + require.False(t, before.matches(after), "replacement must invalidate a stamp even before its first comparison") +} + +func TestDiskStampKeepsIdentityAfterOriginalPathDisappears(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + alias := path + ".link" + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + if err := os.Link(path, alias); err != nil { + t.Skipf("hard links unavailable: %v", err) + } + before, err := statDiskStamp(path) + require.NoError(t, err) + require.NoError(t, os.Remove(path)) + after, err := statDiskStamp(alias) + require.NoError(t, err) + require.True(t, before.matches(after), "identity belongs to the stamped object, not a later lookup of its old path") +} + +func TestDiskStampPreservesLeafType(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.go") + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + for _, path := range []string{path, dir} { + stamp, err := statDiskStamp(path) + require.NoError(t, err) + info, err := os.Lstat(path) + require.NoError(t, err) + require.Equal(t, info.Mode().Type(), stamp.info.Mode().Type()) + } + link := filepath.Join(dir, "symlink.go") + if err := os.Symlink(path, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + stamp, err := statDiskStamp(link) + require.NoError(t, err) + require.NotZero(t, stamp.info.Mode()&os.ModeSymlink, "a disk stamp must not follow the leaf") + missing, err := statDiskStamp(filepath.Join(dir, "absent.go")) + require.NoError(t, err) + require.Nil(t, missing.info) +} diff --git a/internal/mcp/pending_projection_stamp_unix_test.go b/internal/mcp/pending_projection_stamp_unix_test.go new file mode 100644 index 000000000..a351c9d99 --- /dev/null +++ b/internal/mcp/pending_projection_stamp_unix_test.go @@ -0,0 +1,20 @@ +//go:build unix + +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestDiskStampDoesNotOpenFIFO(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipe") + require.NoError(t, unix.Mkfifo(path, 0o600)) + stamp, err := statDiskStamp(path) + require.NoError(t, err) + require.NotZero(t, stamp.info.Mode()&os.ModeNamedPipe) +} diff --git a/internal/mcp/pending_projection_stamp_windows_test.go b/internal/mcp/pending_projection_stamp_windows_test.go new file mode 100644 index 000000000..2c4b39e71 --- /dev/null +++ b/internal/mcp/pending_projection_stamp_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package mcp + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestDiskStampFailsClosedWhenWindowsIdentityCannotBeLoaded(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.go") + require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) + name, err := windows.UTF16PtrFromString(path) + require.NoError(t, err) + // Lstat's attribute lookup still succeeds. Go 1.27.3's lazy file-ID + // loader opens with share=0, which cannot coexist with this real reader. + handle, err := windows.CreateFile(name, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + require.NoError(t, err) + defer windows.CloseHandle(handle) + _, err = os.Lstat(path) + require.NoError(t, err) + stamp, err := statDiskStamp(path) + require.ErrorContains(t, err, "file identity unavailable") + require.Nil(t, stamp.info, "metadata without a captured identity cannot become a trusted stamp") +} From c66d764d4e11ae00ebfee61574786c2156a43215 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:20:49 +0100 Subject: [PATCH 124/138] fix: freeze file identity when taking projection disk stamps --- internal/mcp/pending_projection.go | 7 +++++++ internal/mcp/pending_projection_stamp_test.go | 6 +++--- internal/mcp/pending_projection_stamp_windows_test.go | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index f8c257ca4..6cee3ae78 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -128,6 +128,13 @@ func statDiskStamp(path string) (diskStamp, error) { if err != nil { return diskStamp{}, err } + // Go's Windows Lstat may defer loading file IDs until SameFile. Freeze + // them while this stamp is taken, before a replacement can reuse the + // saved path. SameFile retains Lstat's no-follow semantics and performs + // no content open on Unix, including for FIFOs. Refuse an unknown ID. + if !os.SameFile(info, info) { + return diskStamp{}, &os.PathError{Op: "stat", Path: path, Err: errors.New("projection: file identity unavailable")} + } return diskStamp{info: info}, nil } diff --git a/internal/mcp/pending_projection_stamp_test.go b/internal/mcp/pending_projection_stamp_test.go index 258c91ff8..634a0fd03 100644 --- a/internal/mcp/pending_projection_stamp_test.go +++ b/internal/mcp/pending_projection_stamp_test.go @@ -55,6 +55,9 @@ func TestDiskStampPreservesLeafType(t *testing.T) { require.NoError(t, err) require.Equal(t, info.Mode().Type(), stamp.info.Mode().Type()) } + missing, err := statDiskStamp(filepath.Join(dir, "absent.go")) + require.NoError(t, err) + require.Nil(t, missing.info) link := filepath.Join(dir, "symlink.go") if err := os.Symlink(path, link); err != nil { t.Skipf("symlinks unavailable: %v", err) @@ -62,7 +65,4 @@ func TestDiskStampPreservesLeafType(t *testing.T) { stamp, err := statDiskStamp(link) require.NoError(t, err) require.NotZero(t, stamp.info.Mode()&os.ModeSymlink, "a disk stamp must not follow the leaf") - missing, err := statDiskStamp(filepath.Join(dir, "absent.go")) - require.NoError(t, err) - require.Nil(t, missing.info) } diff --git a/internal/mcp/pending_projection_stamp_windows_test.go b/internal/mcp/pending_projection_stamp_windows_test.go index 2c4b39e71..886801105 100644 --- a/internal/mcp/pending_projection_stamp_windows_test.go +++ b/internal/mcp/pending_projection_stamp_windows_test.go @@ -22,7 +22,7 @@ func TestDiskStampFailsClosedWhenWindowsIdentityCannotBeLoaded(t *testing.T) { windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) require.NoError(t, err) - defer windows.CloseHandle(handle) + defer func() { require.NoError(t, windows.CloseHandle(handle)) }() _, err = os.Lstat(path) require.NoError(t, err) stamp, err := statDiskStamp(path) From 02dec864cae35b1097ce3234d8f9bc648d1d67c0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:25:00 +0100 Subject: [PATCH 125/138] test(mcp): distinguish measured zero parse intervals from missing evidence --- .../edit_symbol_measurement_contract_test.go | 6 ++- .../mcp/source_parse_clock_precision_test.go | 41 +++++++++++++++++++ .../mcp/worktree_batch_integration_test.go | 3 +- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 internal/mcp/source_parse_clock_precision_test.go diff --git a/internal/mcp/edit_symbol_measurement_contract_test.go b/internal/mcp/edit_symbol_measurement_contract_test.go index 6ebd5caf3..5f8b62a12 100644 --- a/internal/mcp/edit_symbol_measurement_contract_test.go +++ b/internal/mcp/edit_symbol_measurement_contract_test.go @@ -106,7 +106,11 @@ func TestEditSymbolMeasurementFixtureUsesActualSpliceAndExplicitAbsence(t *testi parse, present := ack.ObservedExecutionMS["parse"] require.True(t, present) require.NotNil(t, parse) - require.Positive(t, *parse) + require.GreaterOrEqual(t, *parse, 0.0, "actual completed work may have equal monotonic endpoints") + require.Positive(t, ack.Counts["parse"].Started) + require.Equal(t, ack.Counts["parse"].Started, ack.Counts["parse"].Completed) + require.Zero(t, ack.Counts["parse"].NotApplicable) + require.True(t, ack.Disjoint) actual, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, strings.Replace(source, declaration, replacement, 1), string(actual)) diff --git a/internal/mcp/source_parse_clock_precision_test.go b/internal/mcp/source_parse_clock_precision_test.go new file mode 100644 index 000000000..c7fafa44b --- /dev/null +++ b/internal/mcp/source_parse_clock_precision_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMeasuredParseEqualClockTickIsNotMissingOrReversed(t *testing.T) { + at := time.Now() + for _, kind := range []string{"completed_equal", "missing", "reversed"} { + t.Run(kind, func(t *testing.T) { + ack := &editAcknowledgement{entered: at} + if kind != "missing" { + begin := at + if kind == "reversed" { + begin = at.Add(time.Millisecond) + } + lap := ack.beginLapAt(editAckParseGate, begin) + lap.finishAt(at) + } + ack.finishActualAt(at) + ack.finishReplyAt(at, "result") + payload := buildAcknowledgementPayload(ack.snapshot(), "observed") + parse := payload.ObservedExecutionMS["parse"] + if kind == "completed_equal" { + require.Equal(t, "measured", payload.PhaseStates["parse"]) + require.NotNil(t, parse) + require.Zero(t, *parse) + require.EqualValues(t, 1, payload.Counts["parse"].Started) + require.EqualValues(t, 1, payload.Counts["parse"].Completed) + require.Zero(t, payload.Counts["parse"].NotApplicable) + require.True(t, payload.Disjoint) + } else { + require.Nil(t, parse, "no observed parse or a reversed interval cannot acquire zero-duration credit") + require.NotEqual(t, "measured", payload.PhaseStates["parse"]) + } + }) + } +} diff --git a/internal/mcp/worktree_batch_integration_test.go b/internal/mcp/worktree_batch_integration_test.go index 4a89737bb..593596fa1 100644 --- a/internal/mcp/worktree_batch_integration_test.go +++ b/internal/mcp/worktree_batch_integration_test.go @@ -169,7 +169,8 @@ func TestWorktreeBatchFacadeGuardedTwoFileEdit(t *testing.T) { require.EqualValues(t, 1, ack.Snapshot.ParseGate.Started, "parallel file parses share one actual ACK wall interval") require.EqualValues(t, 1, ack.Snapshot.ParseGate.Completed) require.NotNil(t, ack.Snapshot.ParseGate.ObservedMS) - require.Positive(t, *ack.Snapshot.ParseGate.ObservedMS) + require.GreaterOrEqual(t, *ack.Snapshot.ParseGate.ObservedMS, 0.0, "an actually measured interval may share one clock tick") + require.True(t, ack.Snapshot.LapsDisjoint) if root == f.worktree { require.EqualValues(t, 1, ack.Snapshot.TicketAdmission.Completed, "one actual checkout batch admission, not one per file") } From 116f151bc2637328b98011048ef7ada26726db43 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 06:19:11 +0100 Subject: [PATCH 126/138] feat(load): select the owned daemon edit ACK policy Refs #1012. Add explicit local/full startup configuration and workload identity while ordinary omission preserves the full default. Cover actual parser, owned bootstrap config and distinct plan hashes; regenerate the two standard report schemas. --- bench/load/discovery_seed.go | 1 + bench/load/edit_ack_mode_test.go | 134 ++++++++++++++++++++++ bench/load/gates.go | 6 +- bench/load/lifecycle.go | 1 + bench/load/plan.go | 1 + bench/load/run.go | 6 + bench/load/scenario.go | 1 + bench/load/startup_probe.go | 1 + bench/load/testdata/paired-v1.schema.json | 3 + bench/load/testdata/report-v1.schema.json | 3 + 10 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 bench/load/edit_ack_mode_test.go diff --git a/bench/load/discovery_seed.go b/bench/load/discovery_seed.go index cb1c6d807..a8211a6f0 100644 --- a/bench/load/discovery_seed.go +++ b/bench/load/discovery_seed.go @@ -125,6 +125,7 @@ func prepareDiscoveryState(ctx context.Context, o runOptions, corpora []Corpus) if err != nil { return err } + p.editACKMode = o.EditACKMode defer os.RemoveAll(p.SocketDir) complete := false defer func() { diff --git a/bench/load/edit_ack_mode_test.go b/bench/load/edit_ack_mode_test.go new file mode 100644 index 000000000..192fe1286 --- /dev/null +++ b/bench/load/edit_ack_mode_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func editACKModeArgs() []string { + return []string{"--binary", "binary", "--manifest", "manifest", "--scratch", "scratch", "--scenario", "read", "--agents", "1", "--edit-agents", "0", "--worktrees", "0"} +} + +func TestExplicitEditACKModeValidatesOnlyTheTwoConfigurationPolicies(t *testing.T) { + if _, err := parseRun(editACKModeArgs()); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"local", "full", "unknown", "LOCAL"} { + t.Run(mode, func(t *testing.T) { + options, err := parseRun(append(editACKModeArgs(), "--edit-ack", mode)) + if mode != "local" && mode != "full" { + if err == nil { + t.Fatal("invalid startup ACK policy accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(options) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if fields["EditACKMode"] != mode { + t.Fatalf("selected startup policy lost: %s", data) + } + }) + } +} + +func TestExplicitEditACKModeSeparatesWorkloadIdentity(t *testing.T) { + options, err := parseRun(editACKModeArgs()) + if err != nil { + t.Fatal(err) + } + hashes := map[string]string{} + for _, mode := range []string{"", "local", "full"} { + selected := options + // Decode by field name so this regression executes before the new + // option exists; an ignored option cannot manufacture plan identity. + data, err := json.Marshal(map[string]string{"EditACKMode": mode}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &selected); err != nil { + t.Fatal(err) + } + report, err := newRunReport(selected, []Corpus{sampleCorpus()}, strings.Repeat("a", 40), Host{}, "owned-ack-mode") + if err != nil { + t.Fatal(err) + } + fields := copyJSON(report.Workload) + if mode == "" { + if _, exists := fields["edit_ack_mode"]; exists { + t.Fatal("ordinary workload acquired explicit ACK policy metadata") + } + } else if fields["edit_ack_mode"] != mode { + t.Errorf("requested startup mode missing from normalized workload: mode=%q fields=%v", mode, fields) + } + hashes[mode] = report.RequestPlan + } + if hashes["local"] == hashes["full"] { + t.Fatal("local and full ACK configurations have identical workload identity") + } +} + +// Crash the owned fixture before readiness. This proves startup configuration +// only, never effective ACK semantics or any measured edit workload. +func TestExplicitEditACKModeReachesOwnedBootstrapConfig(t *testing.T) { + t.Setenv("LOAD_DAEMON_FIXTURE", "crash") + corpus := gitFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"", "local", "full"} { + name := mode + if name == "" { + name = "ordinary" + } + t.Run(name, func(t *testing.T) { + options := runOptions{Binary: binary, Profile: "agent", Host: "native", Scenario: "read", Agents: 1, Worktrees: 0, Seed: 42, Duration: time.Millisecond, Deadline: time.Second, WarmTimeout: 2 * time.Second, StopTimeout: time.Second, KeepState: true} + data, err := json.Marshal(map[string]string{"EditACKMode": mode}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &options); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + _, _, artifacts, _, _, phaseErr := runPhase(ctx, options, "read", []Corpus{corpus}, "", t.TempDir()) + if phaseErr == nil { + t.Fatal("startup control unexpectedly ran a workload") + } + root := artifacts["private_root"] + if root == "" { + t.Fatalf("startup did not reach owned configuration: %v", phaseErr) + } + data, err = os.ReadFile(filepath.Join(root, "config", "gortex", "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if mode == "" { + if _, exists := config["edit"]; exists { + t.Fatal("ordinary configuration changed default bytes") + } + } else if edit, ok := config["edit"].(map[string]any); !ok || edit["ack"] != mode { + t.Fatalf("owned daemon did not receive selected edit.ack=%s: %s", mode, data) + } + }) + } +} diff --git a/bench/load/gates.go b/bench/load/gates.go index 0489b7ebe..aa851a35b 100644 --- a/bench/load/gates.go +++ b/bench/load/gates.go @@ -127,7 +127,11 @@ func (p *privateRun) writeBootRepos(worktrees bool) ([]privateRepo, error) { for _, repo := range configured { entries = append(entries, map[string]string{"path": repo.Path, "name": repo.Corpus.Label}) } - data, err := json.Marshal(map[string]any{"repos": entries}) + payload := map[string]any{"repos": entries} + if p.editACKMode != "" { + payload["edit"] = map[string]string{"ack": p.editACKMode} + } + data, err := json.Marshal(payload) if err != nil { return nil, err } diff --git a/bench/load/lifecycle.go b/bench/load/lifecycle.go index 8c592b1c2..2e7d2ae70 100644 --- a/bench/load/lifecycle.go +++ b/bench/load/lifecycle.go @@ -22,6 +22,7 @@ type privateRepo struct { } type privateRun struct { + editACKMode string discoveryCatalog map[string]discoveryCatalogIdentity // discovery-only observed seed identities Root string SocketDir string diff --git a/bench/load/plan.go b/bench/load/plan.go index 652d0a513..aa2996a00 100644 --- a/bench/load/plan.go +++ b/bench/load/plan.go @@ -63,6 +63,7 @@ type request struct { // Workload is the complete normalized offered load. Binary paths, private // clone paths and revisions belong to the run identity, never the plan hash. type Workload struct { + EditACKMode string `json:"edit_ack_mode,omitempty"` EditGateSeed string `json:"edit_gate_seed,omitempty"` PRSmokeDrain *PRSmokeDrain `json:"pr_smoke_drain,omitempty"` Scenario string `json:"scenario"` diff --git a/bench/load/run.go b/bench/load/run.go index df4b3a0e6..00d8804e6 100644 --- a/bench/load/run.go +++ b/bench/load/run.go @@ -14,6 +14,7 @@ import ( ) type runOptions struct { + EditACKMode string Binary string Manifest string Scratch string @@ -71,6 +72,7 @@ func parseRun(args []string) (runOptions, error) { } func bindRunFlags(f *flag.FlagSet, o *runOptions) { + f.StringVar(&o.EditACKMode, "edit-ack", "", "owned daemon edit.ack policy: local or full; omit to preserve the full default") f.StringVar(&o.Binary, "binary", "", "Gortex binary (never an existing daemon)") f.StringVar(&o.Manifest, "manifest", "", "pinned JSON corpus manifest; source roots are read-only") f.StringVar(&o.Scratch, "scratch", "", "artifact/private-clone parent outside all source roots") @@ -103,6 +105,9 @@ func bindRunFlags(f *flag.FlagSet, o *runOptions) { } func validateRunOptions(o runOptions) error { + if o.EditACKMode != "" && o.EditACKMode != "local" && o.EditACKMode != "full" { + return errors.New("--edit-ack must be local or full when explicitly set") + } if o.EditGateSeed != "" && (!o.EditGateModes || o.EditGateSeed != "ack-wait" && o.EditGateSeed != "closed-read-gate") { return errors.New("--edit-gate-seed requires --edit-gate-modes and ack-wait or closed-read-gate") } @@ -316,6 +321,7 @@ func newRunReport(o runOptions, corpora []Corpus, sha string, host Host, outputR report.Workload.SessionModel = plan.SessionModel } report.Workload.EditGateSeed = o.EditGateSeed + report.Workload.EditACKMode = o.EditACKMode report.Workload.Scenario, report.Workload.Agents, report.Workload.EditAgents, report.Workload.LinkedWorktrees = o.Scenario, o.Agents, o.Editors, o.Worktrees report.Workload.DurationSec, report.Workload.DeadlineSec, report.Workload.Seed, report.Workload.Profile = o.Duration.Seconds(), o.Deadline.Seconds(), o.Seed, o.Profile if o.PRSmokeDrain > 0 { diff --git a/bench/load/scenario.go b/bench/load/scenario.go index ebee28702..fffc2a6c5 100644 --- a/bench/load/scenario.go +++ b/bench/load/scenario.go @@ -213,6 +213,7 @@ func runPhase(ctx context.Context, o runOptions, scenario string, corpora []Corp } warmCtx, cancelWarm := context.WithTimeout(ctx, o.WarmTimeout) defer cancelWarm() + p.editACKMode = o.EditACKMode var configured []privateRepo if scenario == checkoutDiscoveryScenario { configured, err = p.writeDiscoveryBootRepos() diff --git a/bench/load/startup_probe.go b/bench/load/startup_probe.go index 37bc3f4fc..86b3db3d0 100644 --- a/bench/load/startup_probe.go +++ b/bench/load/startup_probe.go @@ -80,6 +80,7 @@ func runStartupPhase(ctx context.Context, o runOptions, corpora []Corpus, output if err != nil { return phase, nil, artifacts, crashes, "", err } + p.editACKMode = o.EditACKMode artifacts["private_root"] = p.Root defer func() { stopCtx, cancel := context.WithTimeout(context.Background(), o.StopTimeout) diff --git a/bench/load/testdata/paired-v1.schema.json b/bench/load/testdata/paired-v1.schema.json index a61f16391..dfddd844f 100644 --- a/bench/load/testdata/paired-v1.schema.json +++ b/bench/load/testdata/paired-v1.schema.json @@ -3782,6 +3782,9 @@ "duration_seconds": { "type": "number" }, + "edit_ack_mode": { + "type": "string" + }, "edit_agents": { "type": "integer" }, diff --git a/bench/load/testdata/report-v1.schema.json b/bench/load/testdata/report-v1.schema.json index 099d0f23d..1f444d83e 100644 --- a/bench/load/testdata/report-v1.schema.json +++ b/bench/load/testdata/report-v1.schema.json @@ -3769,6 +3769,9 @@ "duration_seconds": { "type": "number" }, + "edit_ack_mode": { + "type": "string" + }, "edit_agents": { "type": "integer" }, From 55bd7f741a66e2470b133f3a3d681b3fe9b51f37 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:52:06 +0100 Subject: [PATCH 127/138] test(mcp): prepare two-session repeated symbol readback control --- ...current_symbol_readback_acceptance_test.go | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 internal/mcp/concurrent_symbol_readback_acceptance_test.go diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go new file mode 100644 index 000000000..ece8cb153 --- /dev/null +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -0,0 +1,135 @@ +//go:build acceptance + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/indexer" +) + +// This is a physical-writer/read-back correctness control. It samples no +// latency and provides no reference-host, WAL, or full-mode performance proof. +func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { + selectedProjectionRegistry(t) + const original = "package sample\n\nfunc first() int {\n\treturn 0\n}\n\nfunc second() int {\n\treturn 0\n}\n" + s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(t, err) + s.SetWatcher(watcher) + require.NoError(t, watcher.Start([]string{root})) + t.Cleanup(func() { require.NoError(t, watcher.Stop()); s.indexer.Close() }) + // Do not change the default policy to make this control complete sooner. + require.Equal(t, config.EditAckFull, s.sourceAckPolicy(t.Context()).mode) + edit := s.wrapToolHandler(s.handleEditSymbol) + read := s.wrapToolHandler(s.handleGetSymbolSource) + invoke := func(ctx context.Context, name string, args map[string]any) (map[string]any, error) { + req := mcplib.CallToolRequest{} + req.Params.Name = name + req.Params.Arguments = args + var result *mcplib.CallToolResult + var err error + if name == "edit_symbol" { + result, err = edit(ctx, req) + } else { + result, err = read(ctx, req) + } + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("%s returned no result", name) + } + if result.IsError { + return nil, fmt.Errorf("%s refused: %s", name, resultText(result)) + } + var fields map[string]any + if err := json.Unmarshal([]byte(resultText(result)), &fields); err != nil { + return nil, err + } + return fields, nil + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + start := make(chan struct{}) + type outcome struct { + name string + completed int + err error + } + outcomes := make(chan outcome, 2) + var workers sync.WaitGroup + for _, name := range []string{"first", "second"} { + workers.Add(1) + go func() { + defer workers.Done() + result := outcome{name: name} + defer func() { outcomes <- result }() + session := WithSessionID(ctx, "repeat-"+name) + <-start + for ordinal := 0; ordinal < 500; ordinal++ { + before, after := ordinal%2, (ordinal+1)%2 + ack, err := invoke(session, "edit_symbol", map[string]any{ + "id": "sample.go::" + name, "old_source": fmt.Sprintf("\treturn %d", before), "new_source": fmt.Sprintf("\treturn %d", after), "dry_run": false, "format": "json", + }) + if err != nil { + result.err = fmt.Errorf("edit %d: %w", ordinal, err) + cancel() + return + } + if ack["committed"] != true || ack["ack_mode"] != "full" { + result.err = fmt.Errorf("edit %d lacks actual committed/full ACK: %v", ordinal, ack) + cancel() + return + } + body, err := invoke(session, "get_symbol_source", map[string]any{"id": "sample.go::" + name, "context_lines": 0, "format": "json"}) + if err != nil { + result.err = fmt.Errorf("read %d: %w", ordinal, err) + cancel() + return + } + want := fmt.Sprintf("func %s() int {\n\treturn %d\n}", name, after) + if body["source"] != want { + result.err = fmt.Errorf("read %d used the wrong repeated fragment: got %q want %q", ordinal, body["source"], want) + cancel() + return + } + result.completed++ + } + }() + } + close(start) + joined := make(chan struct{}) + go func() { workers.Wait(); close(joined) }() + select { + case <-joined: + case <-time.After(6 * time.Minute): + cancel() + t.Fatal("owned concurrent symbol writers did not terminate within the control bound") + } + close(outcomes) + total := 0 + for result := range outcomes { + require.NoError(t, result.err, "session %s completed %d", result.name, result.completed) + require.Equal(t, 500, result.completed) + total += result.completed + } + require.Equal(t, 1000, total) + current, err := os.ReadFile(filepath.Join(root, "sample.go")) + require.NoError(t, err) + require.Equal(t, original, string(current), "500 alternating commits per declaration preserve both repeated fragments") + require.Equal(t, 2, strings.Count(string(current), "\treturn 0")) +} From 82aba08f968ccbb152311986888498d207328632 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 05:52:48 +0100 Subject: [PATCH 128/138] test(mcp): require actual full completion and join owned editors --- .../concurrent_symbol_readback_acceptance_test.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go index ece8cb153..a57d4d71b 100644 --- a/internal/mcp/concurrent_symbol_readback_acceptance_test.go +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -90,7 +90,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { cancel() return } - if ack["committed"] != true || ack["ack_mode"] != "full" { + if ack["committed"] != true || ack["ack_mode"] != "full" || ack["graph_status"] != "fresh" { result.err = fmt.Errorf("edit %d lacks actual committed/full ACK: %v", ordinal, ack) cancel() return @@ -112,14 +112,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { }() } close(start) - joined := make(chan struct{}) - go func() { workers.Wait(); close(joined) }() - select { - case <-joined: - case <-time.After(6 * time.Minute): - cancel() - t.Fatal("owned concurrent symbol writers did not terminate within the control bound") - } + workers.Wait() close(outcomes) total := 0 for result := range outcomes { @@ -128,6 +121,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { total += result.completed } require.Equal(t, 1000, total) + t.Logf("actual committed full/fresh ACKs and exact source readbacks: %d/1000; two sessions, 500 edits each; no latency sample", total) current, err := os.ReadFile(filepath.Join(root, "sample.go")) require.NoError(t, err) require.Equal(t, original, string(current), "500 alternating commits per declaration preserve both repeated fragments") From b0c6a24893619c9fdfac5c5cce970af2bd4f8ca0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:45:39 +0100 Subject: [PATCH 129/138] test: measure planner read-pool recycling within the invocation --- .../planner_stats_freshness_test.go | 76 +++++++++++++++++-- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/internal/graph/store_sqlite/planner_stats_freshness_test.go b/internal/graph/store_sqlite/planner_stats_freshness_test.go index dd2f060dc..8c2eaad64 100644 --- a/internal/graph/store_sqlite/planner_stats_freshness_test.go +++ b/internal/graph/store_sqlite/planner_stats_freshness_test.go @@ -320,13 +320,14 @@ func TestEnsurePlannerStatsFresh_RecyclesReadPool(t *testing.T) { seedNamedGoReceiverFixture(s, "b", 150) writeIndexStateCounters(t, s, 0, "repo", 500, 250) + before := warmPlannerReadPool(t, s) h := mustEnsure(t, s) if !h.Refreshed { t.Fatalf("grown store did not refresh: reason=%q", h.Reason) } stats := s.db.Stats() - if stats.MaxIdleClosed == 0 { - t.Errorf("read pool kept its pre-refresh connections (MaxIdleClosed=0, Idle=%d); "+ + if stats.MaxIdleClosed <= before { + t.Errorf("read pool kept its pre-refresh connections (no new idle close, Idle=%d); "+ "they would plan against the pre-growth statistics for the life of the store", stats.Idle) } if stats.Idle != 0 { @@ -334,18 +335,77 @@ func TestEnsurePlannerStatsFresh_RecyclesReadPool(t *testing.T) { } }) - t.Run("fresh", func(t *testing.T) { - s := freshStatsStore(t, "stats_recycle_fresh.sqlite", 100) + for _, priorClose := range []bool{false, true} { + t.Run(fmt.Sprintf("fresh/prior_close=%t", priorClose), func(t *testing.T) { + s := freshStatsStore(t, "stats_recycle_fresh.sqlite", 100) + warmPlannerReadPool(t, s) + if priorClose { + before := s.db.Stats().MaxIdleClosed + s.db.SetMaxIdleConns(0) + s.db.SetMaxIdleConns(1) + if s.db.Stats().MaxIdleClosed != before+1 { + t.Fatal("prior-close control did not close exactly one warm idle connection") + } + if err := s.db.Ping(); err != nil { + t.Fatal(err) + } + } + before := warmPlannerReadPool(t, s) + if h := mustEnsure(t, s); h.Refreshed { + t.Fatalf("fresh store refreshed: reason=%q", h.Reason) + } + if stats := s.db.Stats(); stats.MaxIdleClosed != before { + t.Errorf("fresh store recycled the read pool (idle closes before=%d after=%d); the recycle is only "+ + "owed by a store that actually rewrote its statistics", before, stats.MaxIdleClosed) + } + }) + } + t.Run("fresh_oracle_rejects_real_recycle", func(t *testing.T) { + s := freshStatsStore(t, "stats_recycle_negative.sqlite", 100) + before := warmPlannerReadPool(t, s) if h := mustEnsure(t, s); h.Refreshed { - t.Fatalf("fresh store refreshed: reason=%q", h.Reason) + t.Fatalf("fresh negative-control store refreshed: reason=%q", h.Reason) } - if stats := s.db.Stats(); stats.MaxIdleClosed != 0 { - t.Errorf("fresh store recycled the read pool (MaxIdleClosed=%d); the recycle is only "+ - "owed by a store that actually rewrote its statistics", stats.MaxIdleClosed) + // Semantic negative: the same zero-delta predicate used above MUST + // reject an actual recycle of the controlled warm read connection. + recycleStatsReadPool(s.db, s.writerDB) + if after := s.db.Stats(); after.MaxIdleClosed != before+1 || after.Idle != 0 { + t.Fatalf("real recycle not detected: before=%d after=%+v", before, after) } }) } +// warmPlannerReadPool excludes ordinary surplus-idle closure from the measured +// interval: one physical read connection may exist, and its idle slot is kept. +// The returned counter is captured only after setup has returned that connection. +func warmPlannerReadPool(t *testing.T, s *Store) int64 { + t.Helper() + s.stopCheckpointLoop() + s.stopMaintenanceLane() + // stopMaintenanceLane has a bounded wait. Refuse to observe the pool + // unless its actual worker has finished, rather than assuming a join. + s.maintenanceSched.Lock() + done := s.maintenanceDone + s.maintenanceSched.Unlock() + if done != nil { + select { + case <-done: + default: + t.Fatal("maintenance worker has not finished before pool observation") + } + } + s.db.SetMaxOpenConns(1) + s.db.SetMaxIdleConns(1) + if err := s.db.Ping(); err != nil { + t.Fatal(err) + } + stats := s.db.Stats() + if stats.InUse != 0 || stats.Idle != 1 || stats.OpenConnections != 1 { + t.Fatalf("read pool is not a single warm idle connection: %+v", stats) + } + return stats.MaxIdleClosed +} + // The payload index_health publishes is read straight off this struct, so what // each field means has to be pinned: believed comes from the named index's // stat row (with the documented nodes_by_file fallback), actual from the From 63583564294115af1f3ed98f4559be2752dc6884 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:52:07 +0100 Subject: [PATCH 130/138] test(mcp): pass the measured clock snapshot by pointer --- internal/mcp/source_parse_clock_precision_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/mcp/source_parse_clock_precision_test.go b/internal/mcp/source_parse_clock_precision_test.go index c7fafa44b..b8922c437 100644 --- a/internal/mcp/source_parse_clock_precision_test.go +++ b/internal/mcp/source_parse_clock_precision_test.go @@ -22,7 +22,8 @@ func TestMeasuredParseEqualClockTickIsNotMissingOrReversed(t *testing.T) { } ack.finishActualAt(at) ack.finishReplyAt(at, "result") - payload := buildAcknowledgementPayload(ack.snapshot(), "observed") + snapshot := ack.snapshot() + payload := buildAcknowledgementPayload(&snapshot, "observed") parse := payload.ObservedExecutionMS["parse"] if kind == "completed_equal" { require.Equal(t, "measured", payload.PhaseStates["parse"]) From 72c87c13efc85f6e91f74bccac1c9040dbd6d1c6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:55:18 +0100 Subject: [PATCH 131/138] test(mcp): assert the actual committed-disk wire verdict in repeated edits --- internal/mcp/concurrent_symbol_readback_acceptance_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go index a57d4d71b..08541214c 100644 --- a/internal/mcp/concurrent_symbol_readback_acceptance_test.go +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -90,7 +90,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { cancel() return } - if ack["committed"] != true || ack["ack_mode"] != "full" || ack["graph_status"] != "fresh" { + if ack["disk_status"] != "committed" || ack["ack_mode"] != "full" || ack["graph_status"] != "fresh" { result.err = fmt.Errorf("edit %d lacks actual committed/full ACK: %v", ordinal, ack) cancel() return From ea88b184a145017f57dcfbba0a1cb119af634df4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 07:58:39 +0100 Subject: [PATCH 132/138] test(mcp): keep repeated-edit fixture independent of read-through test helpers --- internal/mcp/concurrent_symbol_readback_acceptance_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go index 08541214c..040195640 100644 --- a/internal/mcp/concurrent_symbol_readback_acceptance_test.go +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -24,7 +24,9 @@ import ( // This is a physical-writer/read-back correctness control. It samples no // latency and provides no reference-host, WAL, or full-mode performance proof. func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { - selectedProjectionRegistry(t) + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) const original = "package sample\n\nfunc first() int {\n\treturn 0\n}\n\nfunc second() int {\n\treturn 0\n}\n" s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) From eb4dc63ff3fefc04a3fb2f3cedb73494cda4b5b3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:26:04 +0100 Subject: [PATCH 133/138] test(mcp): decode the primary readback payload before session notes --- ...current_symbol_readback_acceptance_test.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go index 040195640..90949be74 100644 --- a/internal/mcp/concurrent_symbol_readback_acceptance_test.go +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -38,6 +39,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { require.Equal(t, config.EditAckFull, s.sourceAckPolicy(t.Context()).mode) edit := s.wrapToolHandler(s.handleEditSymbol) read := s.wrapToolHandler(s.handleGetSymbolSource) + var auxiliaryResponses atomic.Int64 invoke := func(ctx context.Context, name string, args map[string]any) (map[string]any, error) { req := mcplib.CallToolRequest{} req.Params.Name = name @@ -58,9 +60,21 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { if result.IsError { return nil, fmt.Errorf("%s refused: %s", name, resultText(result)) } + if len(result.Content) == 0 { + return nil, fmt.Errorf("%s returned no primary payload", name) + } + primary, ok := result.Content[0].(mcplib.TextContent) + if !ok { + return nil, fmt.Errorf("%s returned a non-text primary payload", name) + } + // Session notes are separate MCP content blocks, not trailing JSON. + // Keep the real middleware enabled and decode only its primary record. + if len(result.Content) > 1 { + auxiliaryResponses.Add(1) + } var fields map[string]any - if err := json.Unmarshal([]byte(resultText(result)), &fields); err != nil { - return nil, err + if err := json.Unmarshal([]byte(primary.Text), &fields); err != nil { + return nil, fmt.Errorf("%s primary JSON: %w; payload=%q", name, err, primary.Text) } return fields, nil } @@ -123,6 +137,7 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { total += result.completed } require.Equal(t, 1000, total) + require.GreaterOrEqual(t, auxiliaryResponses.Load(), int64(2), "both real session-note paths must remain enabled") t.Logf("actual committed full/fresh ACKs and exact source readbacks: %d/1000; two sessions, 500 edits each; no latency sample", total) current, err := os.ReadFile(filepath.Join(root, "sample.go")) require.NoError(t, err) From 6dc580449a8ef6fc442b7b034ac15c894303924d Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 09:11:35 +0100 Subject: [PATCH 134/138] test(mcp): prove repeated edits with an independent symbol-target disk oracle --- ...current_symbol_readback_acceptance_test.go | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/internal/mcp/concurrent_symbol_readback_acceptance_test.go b/internal/mcp/concurrent_symbol_readback_acceptance_test.go index 90949be74..4072d1267 100644 --- a/internal/mcp/concurrent_symbol_readback_acceptance_test.go +++ b/internal/mcp/concurrent_symbol_readback_acceptance_test.go @@ -22,13 +22,14 @@ import ( "github.com/zzet/gortex/internal/indexer" ) -// This is a physical-writer/read-back correctness control. It samples no -// latency and provides no reference-host, WAL, or full-mode performance proof. +// This verifies the #1012 right-symbol write criterion with an independent disk +// oracle. Public source outcomes are a separate two-session census, never the +// #1014 1/8/32-agent acceptance row. No latency or reference-host credit. func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { previous := pendingFileProjections pendingFileProjections = newPendingProjections() t.Cleanup(func() { pendingFileProjections = previous }) - const original = "package sample\n\nfunc first() int {\n\treturn 0\n}\n\nfunc second() int {\n\treturn 0\n}\n" + const original = "package sample\n\nfunc first() int {\n\treturn 0\n}\n\nfunc second() int {\n\treturn 0\n}\n\nfunc decoyZero() int {\n\treturn 0\n}\n\nfunc decoyFirst() int {\n\treturn 11\n}\n\nfunc decoySecond() int {\n\treturn 22\n}\n" s, root := setupMoveInlineRepo(t, map[string]string{"sample.go": original}) watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) require.NoError(t, err) @@ -82,9 +83,12 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { defer cancel() start := make(chan struct{}) type outcome struct { - name string - completed int - err error + name string + completed int + sourceExact int + sourcePending int + sourceRefused int + err error } outcomes := make(chan outcome, 2) var workers sync.WaitGroup @@ -96,8 +100,15 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { defer func() { outcomes <- result }() session := WithSessionID(ctx, "repeat-"+name) <-start + unique := 11 + if name == "second" { + unique = 22 + } for ordinal := 0; ordinal < 500; ordinal++ { - before, after := ordinal%2, (ordinal+1)%2 + before, after := 0, unique + if ordinal%2 == 1 { + before, after = unique, 0 + } ack, err := invoke(session, "edit_symbol", map[string]any{ "id": "sample.go::" + name, "old_source": fmt.Sprintf("\treturn %d", before), "new_source": fmt.Sprintf("\treturn %d", after), "dry_run": false, "format": "json", }) @@ -111,18 +122,44 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { cancel() return } - body, err := invoke(session, "get_symbol_source", map[string]any{"id": "sample.go::" + name, "context_lines": 0, "format": "json"}) + body, readErr := invoke(session, "get_symbol_source", map[string]any{"id": "sample.go::" + name, "context_lines": 0, "format": "json"}) + want := fmt.Sprintf("func %s() int {\n\treturn %d\n}", name, after) + switch { + case readErr != nil: + result.sourceRefused++ + case body["source"] == want: + result.sourceExact++ + case body["source"] == nil && body["source_state"] == "pending" && body["complete"] == false: + result.sourcePending++ + default: + result.err = fmt.Errorf("read %d returned an incorrect source payload: want %q response=%v", ordinal, want, body) + cancel() + return + } + // Stabilize only the independent physical oracle, AFTER the actual + // immediate public read above. This adds no wait to that probe and + // prevents a peer's temporary rename from racing the oracle's open. + path := filepath.Join(root, "sample.go") + _, release, err := acquireMutationPaths(session, []string{path}) if err != nil { - result.err = fmt.Errorf("read %d: %w", ordinal, err) + result.err = err cancel() return } - want := fmt.Sprintf("func %s() int {\n\treturn %d\n}", name, after) - if body["source"] != want { - result.err = fmt.Errorf("read %d used the wrong repeated fragment: got %q want %q", ordinal, body["source"], want) + current, diskErr := os.ReadFile(path) + release() + if diskErr != nil || !strings.Contains(string(current), want) { + result.err = fmt.Errorf("disk oracle edit %d must land in %s: err=%v want=%q actual=%q", ordinal, name, diskErr, want, current) cancel() return } + for _, decoy := range []string{"func decoyZero() int {\n\treturn 0\n}", "func decoyFirst() int {\n\treturn 11\n}", "func decoySecond() int {\n\treturn 22\n}"} { + if !strings.Contains(string(current), decoy) { + result.err = fmt.Errorf("edit %d changed an unrelated repeated fragment: %q", ordinal, decoy) + cancel() + return + } + } result.completed++ } }() @@ -130,17 +167,21 @@ func TestEditSymbolTwoSessionsRepeatedFragment1000(t *testing.T) { close(start) workers.Wait() close(outcomes) - total := 0 + total, sourceExact, sourcePending, sourceRefused := 0, 0, 0, 0 for result := range outcomes { require.NoError(t, result.err, "session %s completed %d", result.name, result.completed) require.Equal(t, 500, result.completed) total += result.completed + sourceExact += result.sourceExact + sourcePending += result.sourcePending + sourceRefused += result.sourceRefused } require.Equal(t, 1000, total) - require.GreaterOrEqual(t, auxiliaryResponses.Load(), int64(2), "both real session-note paths must remain enabled") - t.Logf("actual committed full/fresh ACKs and exact source readbacks: %d/1000; two sessions, 500 edits each; no latency sample", total) + require.Equal(t, 1000, sourceExact+sourcePending+sourceRefused, "every immediate public read outcome remains accounted for") + t.Logf("#1012 physical right-symbol writes=%d/1000, default full/fresh ACKs; unrelated repeated fragments unchanged", total) + t.Logf("separate two-session public source census: exact=%d pending=%d refused=%d auxiliary_note_responses=%d; NO #1014 1/8/32-agent or latency acceptance credit", sourceExact, sourcePending, sourceRefused, auxiliaryResponses.Load()) current, err := os.ReadFile(filepath.Join(root, "sample.go")) require.NoError(t, err) require.Equal(t, original, string(current), "500 alternating commits per declaration preserve both repeated fragments") - require.Equal(t, 2, strings.Count(string(current), "\treturn 0")) + require.Equal(t, 3, strings.Count(string(current), "\treturn 0")) } From faf61334bc6a5bcb46392877810cec1bd4aa246f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 08:56:58 +0100 Subject: [PATCH 135/138] test: force genuinely unavailable Windows stamp identity --- internal/mcp/pending_projection.go | 4 ++++ .../pending_projection_stamp_windows_test.go | 19 +++++++------------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/internal/mcp/pending_projection.go b/internal/mcp/pending_projection.go index 6cee3ae78..db21315dd 100644 --- a/internal/mcp/pending_projection.go +++ b/internal/mcp/pending_projection.go @@ -128,6 +128,10 @@ func statDiskStamp(path string) (diskStamp, error) { if err != nil { return diskStamp{}, err } + return freezeDiskStampIdentity(path, info) +} + +func freezeDiskStampIdentity(path string, info os.FileInfo) (diskStamp, error) { // Go's Windows Lstat may defer loading file IDs until SameFile. Freeze // them while this stamp is taken, before a replacement can reuse the // saved path. SameFile retains Lstat's no-follow semantics and performs diff --git a/internal/mcp/pending_projection_stamp_windows_test.go b/internal/mcp/pending_projection_stamp_windows_test.go index 886801105..340b223ad 100644 --- a/internal/mcp/pending_projection_stamp_windows_test.go +++ b/internal/mcp/pending_projection_stamp_windows_test.go @@ -8,24 +8,19 @@ import ( "testing" "github.com/stretchr/testify/require" - "golang.org/x/sys/windows" ) func TestDiskStampFailsClosedWhenWindowsIdentityCannotBeLoaded(t *testing.T) { path := filepath.Join(t.TempDir(), "file.go") require.NoError(t, os.WriteFile(path, []byte("package example\n"), 0o600)) - name, err := windows.UTF16PtrFromString(path) + info, err := os.Lstat(path) require.NoError(t, err) - // Lstat's attribute lookup still succeeds. Go 1.27.3's lazy file-ID - // loader opens with share=0, which cannot coexist with this real reader. - handle, err := windows.CreateFile(name, windows.GENERIC_READ, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) - require.NoError(t, err) - defer func() { require.NoError(t, windows.CloseHandle(handle)) }() - _, err = os.Lstat(path) - require.NoError(t, err) - stamp, err := statDiskStamp(path) + // Exercise the real race between Lstat metadata and eager ID capture. + // Go 1.27.3 retains the path for lazy identity loading; removing that + // path makes the ID unavailable without relying on sharing semantics. + // Do not call SameFile before removal, which would initialize the ID. + require.NoError(t, os.Remove(path)) + stamp, err := freezeDiskStampIdentity(path, info) require.ErrorContains(t, err, "file identity unavailable") require.Nil(t, stamp.info, "metadata without a captured identity cannot become a trusted stamp") } From a2186e661b3f8a32ea4f8637239a60214f62da3a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 09:20:56 +0100 Subject: [PATCH 136/138] test(mcp): separate live tier evidence from observed source invalidation --- internal/mcp/source_projection_tiers_test.go | 101 ++++++++++++++----- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/internal/mcp/source_projection_tiers_test.go b/internal/mcp/source_projection_tiers_test.go index e7d89b254..ac31cc2da 100644 --- a/internal/mcp/source_projection_tiers_test.go +++ b/internal/mcp/source_projection_tiers_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "path/filepath" "testing" "time" @@ -10,37 +11,81 @@ import ( "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" ) +type controlledTierEvidenceWatcher struct { + *indexer.Watcher + invalidate bool + observed chan bool +} + +func (w *controlledTierEvidenceWatcher) EnqueueToolMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + entry := pendingFileProjections.snap.Load().entries[pathkey.MutationKey(path)] + current := entry != nil && liveProjectionForAcknowledgement(entry) + if w.invalidate { + pendingFileProjections.markSuspect(path) + } + w.observed <- current + return w.Watcher.EnqueueToolMutation(ctx, path) +} + func TestMutationAckIncludesExistingTierProducerEvidence(t *testing.T) { - s, root := setupTestServer(t) - watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) - require.NoError(t, err) - s.SetWatcher(watcher) - require.NoError(t, watcher.Start([]string{root})) - t.Cleanup(func() { require.NoError(t, watcher.Stop()) }) - s.mutationReindexWait = time.Nanosecond - result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 3 }", "format": "json"}) - require.False(t, result.IsError, "%v", result.Content) - response := extractTextResult(t, result) - require.NotZero(t, response["reindex_generation"], "prove real watcher admission before checking evidence") - states, ok := response["tier_state"].(map[string]any) - require.True(t, ok, "the acknowledgement must consume the existing ticket-owned tier producer") - require.Len(t, states, 4) - require.Equal(t, "fresh", states["file"], "only the exact live physical projection supplies this additional tier") - ack, ok := response["ack_at"].(string) - require.True(t, ok) - at, err := time.Parse(time.RFC3339Nano, ack) - require.NoError(t, err) - require.False(t, at.IsZero()) - for tier, field := range map[string]string{"file_graph": "file_graph_at", "relations": "relations_at", "derived": "derived_at"} { - require.Contains(t, []string{"pending", "fresh", "partial", "failed"}, states[tier]) - if states[tier] == "fresh" { - value, ok := response[field].(string) - require.True(t, ok, "fresh %s must have actual publication time", tier) - stamp, err := time.Parse(time.RFC3339Nano, value) + for _, invalidate := range []bool{false, true} { + t.Run(map[bool]string{false: "live_projection", true: "observed_invalidation"}[invalidate], func(t *testing.T) { + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + t.Cleanup(func() { pendingFileProjections = previous }) + s, root := setupTestServer(t) + watcher, err := indexer.NewWatcher(s.indexer, config.WatchConfig{Enabled: true, DebounceMs: 60000}, zap.NewNop()) + require.NoError(t, err) + controlled := &controlledTierEvidenceWatcher{Watcher: watcher, invalidate: invalidate, observed: make(chan bool, 1)} + s.SetWatcher(controlled) + // Keep native delivery independent of this manual tool-admission test. + require.NoError(t, watcher.Start([]string{t.TempDir()})) + s.graph.ResolveMutex().Lock() + var receipt string + t.Cleanup(func() { + s.graph.ResolveMutex().Unlock() + if receipt != "" { + waitCheckoutReceipt(t, s, receipt) + } + require.NoError(t, watcher.Stop()) + }) + s.mutationReindexWait = time.Nanosecond + result := callTool(t, s, "edit_file", map[string]any{"path": filepath.Join(root, "main.go"), "old_string": "func helper() {}", "new_string": "func helper() { _ = 3 }", "format": "json"}) + require.False(t, result.IsError, "%v", result.Content) + response := extractTextResult(t, result) + receipt, _ = response["reindex_receipt"].(string) + require.True(t, <-controlled.observed, "the real committed projection must be live before controlled invalidation") + require.NotZero(t, response["reindex_generation"], "prove real watcher admission before checking evidence") + states, ok := response["tier_state"].(map[string]any) + require.True(t, ok, "the acknowledgement must consume the existing ticket-owned tier producer") + require.Len(t, states, 4) + if invalidate { + require.Equal(t, "pending", states["file"], "observed invalidation cannot receive exact-file credit") + require.Equal(t, false, response["readback_exact"]) + require.Equal(t, "projection_not_live", response["file_reason"]) + } else { + require.Equal(t, "fresh", states["file"], "the live physical projection must supply the additional file tier") + require.Equal(t, true, response["readback_exact"]) + require.Equal(t, "projected", response["file_view"].(map[string]any)["state"]) + } + ack, ok := response["ack_at"].(string) + require.True(t, ok) + at, err := time.Parse(time.RFC3339Nano, ack) require.NoError(t, err) - require.False(t, stamp.IsZero()) - } + require.False(t, at.IsZero()) + for tier, field := range map[string]string{"file_graph": "file_graph_at", "relations": "relations_at", "derived": "derived_at"} { + require.Contains(t, []string{"pending", "fresh", "partial", "failed"}, states[tier]) + if states[tier] == "fresh" { + value, ok := response[field].(string) + require.True(t, ok, "fresh %s must have actual publication time", tier) + stamp, err := time.Parse(time.RFC3339Nano, value) + require.NoError(t, err) + require.False(t, stamp.IsZero()) + } + } + }) } } From b6a01e5d0532541328c679347050a89df87115ee Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 10:11:03 +0100 Subject: [PATCH 137/138] revert(reach): stage repository topology after edit acknowledgements Restore the existing global topology gate for the pre-acknowledgement integration parent. Defer the repository-scoped topology, detached SQLite reads, bounded-retry policy and optional mutation-mutex diagnostics to the existing topology change after the acknowledgement producer lands. Retain the generic lookup wait observer and MCP response metadata, observing actual global-gate and resolver-mutex admission. Preserve the upgrade warmup boundary, freshness callbacks and all unrelated frontend changes. The original feature commits remain in history. Reverting this removal commit after the new acknowledgement parent restores the topology feature without rewriting the existing issue branch. --- internal/graph/store_sqlite/reach_coupling.go | 98 --- internal/indexer/canonical_attempt_window.go | 2 +- internal/indexer/canonical_patch_laps.go | 12 +- internal/indexer/edit_phase_timings.go | 15 +- internal/indexer/incremental_derived.go | 3 +- internal/indexer/incremental_watcher_batch.go | 33 +- internal/indexer/multi.go | 30 +- .../indexer/mutation_resolve_mutex_wait.go | 110 --- .../mutation_resolve_mutex_wait_test.go | 256 ------- internal/indexer/repo_topology_gates_test.go | 222 ------ internal/indexer/repo_topology_test.go | 77 -- internal/indexer/workspace_resolve.go | 12 +- internal/reach/coordination_wait.go | 20 +- internal/reach/coordination_wait_test.go | 32 +- internal/reach/global_admission_test.go | 39 + internal/reach/reach.go | 685 ++++-------------- internal/reach/reach_retry_test.go | 52 -- internal/reach/reach_sqlite_test.go | 61 -- internal/reach/topology_bubble_test.go | 83 --- internal/reach/topology_repo_test.go | 324 --------- internal/resolver/cross_repo_incremental.go | 15 - internal/resolver/resolve_mutex_observer.go | 24 - .../resolver/resolve_mutex_observer_test.go | 124 ---- internal/resolver/resolver.go | 14 +- 24 files changed, 233 insertions(+), 2110 deletions(-) delete mode 100644 internal/graph/store_sqlite/reach_coupling.go delete mode 100644 internal/indexer/mutation_resolve_mutex_wait.go delete mode 100644 internal/indexer/mutation_resolve_mutex_wait_test.go delete mode 100644 internal/indexer/repo_topology_gates_test.go delete mode 100644 internal/indexer/repo_topology_test.go create mode 100644 internal/reach/global_admission_test.go delete mode 100644 internal/reach/reach_retry_test.go delete mode 100644 internal/reach/topology_bubble_test.go delete mode 100644 internal/reach/topology_repo_test.go delete mode 100644 internal/resolver/resolve_mutex_observer.go delete mode 100644 internal/resolver/resolve_mutex_observer_test.go diff --git a/internal/graph/store_sqlite/reach_coupling.go b/internal/graph/store_sqlite/reach_coupling.go deleted file mode 100644 index 1d0311a5f..000000000 --- a/internal/graph/store_sqlite/reach_coupling.go +++ /dev/null @@ -1,98 +0,0 @@ -package store_sqlite - -import ( - "context" -) - -// repoCouplingPairsSQL lists the distinct (source, target) repository pairs an -// edge joins. An empty prefix stands for nodes no repository owns; such a -// target counts only when it has edges of its own, since only then can a path -// run through it from one repository into another. Edges whose endpoints share -// a plain leading "/" segment belong to one repository and are skipped -// before the node joins. That is most of the table, so one sequential scan -// beats walking an index and fetching every row it names. -const repoCouplingPairsSQL = ` -SELECT DISTINCT nf.repo_prefix, nt.repo_prefix -FROM edges e NOT INDEXED -JOIN nodes nf ON nf.id = e.from_id AND nf.view_gen = e.view_gen -JOIN nodes nt ON nt.id = e.to_id AND nt.view_gen = e.view_gen -WHERE e.view_gen = ? - AND NOT (instr(e.from_id, '/') > 1 - AND instr(substr(e.from_id, 1, instr(e.from_id, '/')), ':') = 0 - AND substr(e.to_id, 1, instr(e.from_id, '/')) = substr(e.from_id, 1, instr(e.from_id, '/'))) - AND nf.repo_prefix <> nt.repo_prefix - AND (nt.repo_prefix <> '' OR EXISTS ( - SELECT 1 FROM edges o WHERE o.view_gen = e.view_gen AND o.from_id = e.to_id))` - -// repoCouplingPairsForFilesSQL is repoCouplingPairsSQL over the edges sited in -// a list of files or incident to their nodes. CROSS JOIN keeps that frontier -// the driving loop, as in the cross-repository candidate query. An unowned -// node with an edge into the frontier may have just gained its first one, so -// the repositories with edges into it are listed as coupled with it too. -const repoCouplingPairsForFilesSQL = ` -WITH frontier(id) AS ( - SELECT e.id FROM edges e - WHERE e.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) - UNION - SELECT e.id FROM nodes n JOIN edges e ON e.from_id = n.id AND e.view_gen = n.view_gen - WHERE n.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) - UNION - SELECT e.id FROM nodes n JOIN edges e ON e.to_id = n.id AND e.view_gen = n.view_gen - WHERE n.file_path IN (SELECT CAST(value AS TEXT) FROM json_each(?)) -), -joined(from_id, to_id, from_repo, to_repo) AS ( - SELECT e.from_id, e.to_id, nf.repo_prefix, nt.repo_prefix - FROM frontier f CROSS JOIN edges e ON e.id = f.id - JOIN nodes nf ON nf.id = e.from_id AND nf.view_gen = e.view_gen - JOIN nodes nt ON nt.id = e.to_id AND nt.view_gen = e.view_gen - WHERE e.view_gen = ? AND nf.repo_prefix <> nt.repo_prefix -) -SELECT from_repo, to_repo FROM joined j -WHERE j.to_repo <> '' OR EXISTS (SELECT 1 FROM edges o WHERE o.view_gen = ? AND o.from_id = j.to_id) -UNION -SELECT ni.repo_prefix, '' -FROM joined j -JOIN edges i ON i.to_id = j.from_id AND i.view_gen = ? -JOIN nodes ni ON ni.id = i.from_id AND ni.view_gen = i.view_gen -WHERE j.from_repo = '' AND ni.repo_prefix <> ''` - -// RepoCouplingPairs reports which repositories the graph's edges join, so the -// reach index can tell which repositories a mutation of one may affect. It -// reads every edge of the handle's generation. -func (s *Store) RepoCouplingPairs(ctx context.Context) ([][2]string, error) { - return s.repoCouplingPairs(ctx, repoCouplingPairsSQL, s.viewGen) -} - -// RepoCouplingPairsForFiles is RepoCouplingPairs for the edges a mutation of -// the given files can have added or rebound. -func (s *Store) RepoCouplingPairsForFiles(ctx context.Context, files []string) ([][2]string, error) { - scope, ok := projectionJSON(files) - if !ok { - return nil, nil - } - return s.repoCouplingPairs(ctx, repoCouplingPairsForFilesSQL, scope, scope, scope, s.viewGen, s.viewGen, s.viewGen) -} - -func (s *Store) repoCouplingPairs(ctx context.Context, query string, args ...any) ([][2]string, error) { - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var pairs [][2]string - for rows.Next() { - var pair [2]string - if err := rows.Scan(&pair[0], &pair[1]); err != nil { - return nil, err - } - pairs = append(pairs, pair) - } - return pairs, rows.Err() -} - -// NodeReadsDetached reports that reads stay consistent without ResolveMutex: -// each one runs on its own SQLite snapshot and returns decoded copies, never -// the rows a resolver pass is rewriting. -func (s *Store) NodeReadsDetached() bool { - return true -} diff --git a/internal/indexer/canonical_attempt_window.go b/internal/indexer/canonical_attempt_window.go index 0a5568e42..4cfb83d65 100644 --- a/internal/indexer/canonical_attempt_window.go +++ b/internal/indexer/canonical_attempt_window.go @@ -100,7 +100,7 @@ func (p *PublicationPhaseRecord) completePhysicalAttempt(stamps *PublicationStam } } if window.accounting.observed { - if p.patchLaps == nil || !p.patchLaps.sameAccounting(lateDeep) || p.admissions == nil || *p.admissions != lateAdmissions || + if p.patchLaps == nil || *p.patchLaps != lateDeep || p.admissions == nil || *p.admissions != lateAdmissions || (p.laneWait == nil && (lateLane.started || lateLane.invalid)) || (p.laneWait != nil && *p.laneWait != lateLane) || !window.accounting.agreesWithAdmissions(lateAdmissions, lateLane) { window.accounting.invalid = true diff --git a/internal/indexer/canonical_patch_laps.go b/internal/indexer/canonical_patch_laps.go index ff1a3e2c7..650e616e9 100644 --- a/internal/indexer/canonical_patch_laps.go +++ b/internal/indexer/canonical_patch_laps.go @@ -43,7 +43,6 @@ type canonicalLapTotal struct { } type canonicalPatchLaps struct { - resolveMutexWait mutationResolveMutexWait laps [canonicalPatchLapCount]canonicalLapTotal lastEnd time.Time active int @@ -127,9 +126,6 @@ func (p *canonicalPatchLaps) beginLapLocked(lap canonicalPatchLap, at time.Time) } func (p *canonicalPatchLaps) endLapLocked(lap canonicalPatchLap, start, at time.Time) { - if lap == patchResolve { - p.resolveMutexWait.finishResolveSpan(start, at) - } v := &p.laps[lap] if at.IsZero() || at.Before(start) || p.active != 1 || p.activeKind != lap || !p.activeStarted.Equal(start) { p.invalid = true @@ -187,16 +183,15 @@ type canonicalLapEvidence struct { } type canonicalPatchEvidence struct { - resolveMutexWait mutationResolveMutexEvidence - laps [canonicalDeepLapCount]canonicalLapEvidence - invalid bool + laps [canonicalDeepLapCount]canonicalLapEvidence + invalid bool } // freeze converts only same-process monotonic readings, never wall strings. // Called under the collector lock; the returned value has no mutable aliases. func (p *canonicalPatchLaps) freeze(origin time.Time) canonicalPatchEvidence { openOuterControl := p.controlObserved && p.active == 1 && isAttemptControl(p.activeKind) - out := canonicalPatchEvidence{invalid: p.invalid || p.active != 0 && !openOuterControl, resolveMutexWait: p.resolveMutexWait.freeze(origin)} + out := canonicalPatchEvidence{invalid: p.invalid || p.active != 0 && !openOuterControl} for i, v := range p.laps[:canonicalDeepLapCount] { out.laps[i] = canonicalLapEvidence{totalNS: int64(v.total), begun: v.begun, done: v.done, incomplete: v.incomplete} if v.begun != 0 { @@ -259,7 +254,6 @@ func (p *canonicalPatchEvidence) project(offsets map[PublicationPhase]int64, ter ms := float64(v.totalNS) / 1e6 out.Milliseconds[name], out.States[name] = &ms, "measured" } - out.Window.ResolveMutexWait = p.resolveMutexWait.project(p.laps[patchResolve], invalid || !hasStart || !hasEnd) } func (s *PublicationStamps) beginDerivedPatchLap() canonicalLapSpan { diff --git a/internal/indexer/edit_phase_timings.go b/internal/indexer/edit_phase_timings.go index 37f3796ca..df3d15387 100644 --- a/internal/indexer/edit_phase_timings.go +++ b/internal/indexer/edit_phase_timings.go @@ -17,14 +17,13 @@ type EditPhaseTimings struct { // identify the acknowledgement's default reindex wait. It is not a phase // measurement or a claim of complete phase accounting. type EditTimingWindow struct { - ResolveMutexWait *EditResolveMutexWait `json:"resolve_mutex_wait,omitempty"` - Clock string `json:"clock"` - Coverage string `json:"coverage"` - AcknowledgementWallMS *float64 `json:"ack_wall_ms"` - PatchWallMS *float64 `json:"patch_wall_ms"` - PatchAccounting *EditPatchAccounting `json:"patch_accounting,omitempty"` - PublicationWallMS *float64 `json:"publication_wall_ms"` - PublicationUnattributedWallMS *float64 `json:"publication_unattributed_ms"` + Clock string `json:"clock"` + Coverage string `json:"coverage"` + AcknowledgementWallMS *float64 `json:"ack_wall_ms"` + PatchWallMS *float64 `json:"patch_wall_ms"` + PatchAccounting *EditPatchAccounting `json:"patch_accounting,omitempty"` + PublicationWallMS *float64 `json:"publication_wall_ms"` + PublicationUnattributedWallMS *float64 `json:"publication_unattributed_ms"` } var editAckPhaseNames = [...]string{ diff --git a/internal/indexer/incremental_derived.go b/internal/indexer/incremental_derived.go index b8ad4e254..2026f0b17 100644 --- a/internal/indexer/incremental_derived.go +++ b/internal/indexer/incremental_derived.go @@ -213,8 +213,7 @@ func (mi *MultiIndexer) RunIncrementalDerivedPasses( } // runIncrementalDerivedPassesTopologyHeld is the non-reentrant implementation -// for mutation pipelines that already own the topology-writer gates of every -// repository the mutation can affect. +// for mutation pipelines that already own the global topology writer. func (mi *MultiIndexer) runIncrementalDerivedPassesTopologyHeld( ctx context.Context, plans map[string]DerivedInvalidationPlan, diff --git a/internal/indexer/incremental_watcher_batch.go b/internal/indexer/incremental_watcher_batch.go index 88fbd57dc..b54c48c83 100644 --- a/internal/indexer/incremental_watcher_batch.go +++ b/internal/indexer/incremental_watcher_batch.go @@ -336,7 +336,7 @@ func (idx *Indexer) incrementalEvictWatcherPath(path string) (nodesRemoved, edge idx.incrementalResolveFilesHook(frontier) return } - idx.resolver.ResolveFilesAndIncomingObserved(frontier, eviction.batch.publicationStamps().mutationResolveMutexObserver()) + idx.resolver.ResolveFilesAndIncoming(frontier) }) } else if needed && !exact { idx.observeIncrementalCatchup("resolve", nil) @@ -395,7 +395,7 @@ func (idx *Indexer) incrementalWatcherPaths(root string, paths []string, mode in idx.incrementalResolveFilesHook(frontier) return } - idx.resolver.ResolveFilesAndIncomingObserved(frontier, batch.publicationStamps().mutationResolveMutexObserver()) + idx.resolver.ResolveFilesAndIncoming(frontier) }) names := mode.publication.beginPatchLap(patchResolve) idx.resolveReceiptNamePendings(receipt) @@ -558,15 +558,13 @@ func (gw *GitWatcher) reindexChangedPaths(paths []string) (*IndexResult, error) // when the store cannot certify its eviction shape, the indexer's conservative // successful-file frontier. A whole-graph fallback is reserved for the // exceptional case where neither source yields any safe scope. -// It returns the files where passes may have rebound edges, or nil when a -// name-based or whole-graph pass may have changed edges outside that frontier. func (mi *MultiIndexer) resolveIncrementalRepoMutation( repoPrefix string, result *IndexResult, receipt *graph.MutationReceipt, batch *reparsePendingEnrichmentBatch, -) []string { - return mi.resolveIncrementalRepoMutationMode(repoPrefix, result, receipt, batch, false) +) { + mi.resolveIncrementalRepoMutationMode(repoPrefix, result, receipt, batch, false) } func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( @@ -575,9 +573,8 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( receipt *graph.MutationReceipt, batch *reparsePendingEnrichmentBatch, exactPointSemantic bool, -) []string { +) { files, needed, _ := incrementalResolutionFrontier(result, receipt) - wide := false if result == nil || result.DeletedFileCount > 0 { batch.dropDeferredPriorBindings() } @@ -595,29 +592,24 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( idx.incrementalResolveFilesHook(frontier) return } - mi.runMasterResolveFilesObserved(frontier, false, idx.resolver, batch.publicationStamps().mutationResolveMutexObserver()) + mi.runMasterResolveFilesWithEvidence(frontier, false, idx.resolver) }) crossRepoFiles = appendUniqueSorted(crossRepoFiles, resolvedFiles...) if receipt != nil && receipt.Complete { names := batch.publicationStamps().beginPatchLap(patchResolve) - vanished := vanishedReceiptNames(mi.graph, receipt) - wide = len(vanished) > 0 - mi.runMasterResolveNames(vanished) + mi.runMasterResolveNames(vanishedReceiptNames(mi.graph, receipt)) names.end() } } else if needed && len(files) > 0 { resolve := batch.publicationStamps().beginPatchLap(patchResolve) - mi.runMasterResolveFilesObserved(files, false, nil, batch.publicationStamps().mutationResolveMutexObserver()) + mi.runMasterResolveFiles(files, false) resolve.end() if receipt != nil && receipt.Complete { names := batch.publicationStamps().beginPatchLap(patchResolve) - vanished := vanishedReceiptNames(mi.graph, receipt) - wide = len(vanished) > 0 - mi.runMasterResolveNames(vanished) + mi.runMasterResolveNames(vanishedReceiptNames(mi.graph, receipt)) names.end() } } else if needed { - wide = true scope := map[string]struct{}{repoPrefix: {}} if repoPrefix == "" { scope = nil @@ -652,15 +644,10 @@ func (mi *MultiIndexer) resolveIncrementalRepoMutationMode( } cross := batch.publicationStamps().beginPatchLap(patchResolve) if len(crossRepoFiles) > 0 { - mi.runCrossRepoResolveMutationFrontiersObserved(crossRepoFiles, crossRepoFiles, crossRepoFiles, batch.publicationStamps().mutationResolveMutexObserver()) + mi.runCrossRepoResolveFiles(crossRepoFiles) } else { - wide = true mi.runCrossRepoResolve(false) } cross.end() } - if wide { - return nil - } - return crossRepoFiles } diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 99a7198eb..3df9da2d9 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -1087,17 +1087,13 @@ func (mi *MultiIndexer) runMasterResolveFiles(files []string, useLSP bool) { // holding the mutation's pre-eviction evidence (resolveWithDeferredEvidence), // which the master resolver inherits for this one resolve. func (mi *MultiIndexer) runMasterResolveFilesWithEvidence(files []string, useLSP bool, evidence *resolver.Resolver) { - mi.runMasterResolveFilesObserved(files, useLSP, evidence, nil) -} - -func (mi *MultiIndexer) runMasterResolveFilesObserved(files []string, useLSP bool, evidence *resolver.Resolver, observe func(time.Time, time.Time)) { master := mi.newMasterResolver(useLSP) if master == nil { return } master.InheritIncrementalEvidence(evidence) mt := time.Now() - stats := master.ResolveFilesAndIncomingObserved(files, observe) + stats := master.ResolveFilesAndIncoming(files) mi.logger.Info("DEFERRED-TIMING master.ResolveFilesAndIncoming", zap.Duration("elapsed", time.Since(mt)), zap.Bool("lsp_enabled", useLSP && mi.resolverLSPHelper != nil), @@ -2527,10 +2523,9 @@ func (mi *MultiIndexer) incrementalEvictRepoRaw(repoPrefix, path string) (nodesR return 0, 0, fmt.Errorf("repository mutation executor has no live indexer: %s", repoPrefix) } - finishTopologyMutation := reach.BeginRepoTopologyMutation(mi.graph, repoPrefix) + finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) topologyChanged := true - var topologyFrontier []string - defer func() { finishTopologyMutation(topologyChanged, topologyFrontier) }() + defer func() { finishTopologyMutation(topologyChanged) }() eviction := idx.evictFileIncrementalRaw(path) result := eviction.result @@ -2540,7 +2535,7 @@ func (mi *MultiIndexer) incrementalEvictRepoRaw(repoPrefix, path string) (nodesR ) } if result.DeletedFileCount > 0 { - topologyFrontier = mi.resolveIncrementalRepoMutation(repoPrefix, result, nil, eviction.batch) + mi.resolveIncrementalRepoMutation(repoPrefix, result, nil, eviction.batch) } fileMtimes := idx.publishFileMtimes() @@ -2593,22 +2588,19 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ } // The stable per-repository lane is held before this executor runs. Take - // the repository's reachability topology gates second and keep them through - // the resolver, metadata, and derived tails to avoid lane/gate inversion. + // the global reachability topology gate second and keep it through the + // resolver, metadata, and derived tails to avoid lane/gate inversion. gate := mode.publication.beginPatchLap(patchGateWait) - finishTopologyMutation := reach.BeginRepoTopologyMutation(mi.graph, repoPrefix) + finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) gate.end() topologyChanged := true - var topologyFrontier []string - defer func() { finishTopologyMutation(topologyChanged, topologyFrontier) }() + defer func() { finishTopologyMutation(topologyChanged) }() - goModChanged := false for _, path := range paths { if filepath.Base(path) == "go.mod" { mi.goModuleMu.Lock() mi.goModuleChecked = time.Time{} mi.goModuleMu.Unlock() - goModChanged = true break } } @@ -2622,13 +2614,9 @@ func (mi *MultiIndexer) incrementalReindexRepoRawMode(repoPrefix string, paths [ // changed/definition file frontier; only an incomplete receipt falls back to // conservative scoped-global resolution. Derived invalidations run once // below after both binding layers are current. - frontier := mi.resolveIncrementalRepoMutationMode( + mi.resolveIncrementalRepoMutationMode( repoPrefix, result, receipt, batch, mode.exactPointSemantic, ) - // Module changes and full retracks may rebind edges outside the frontier. - if !goModChanged && !result.FullRetrack { - topologyFrontier = frontier - } mtimes := mode.publication.beginPatchLap(patchMtimes) fileMtimes := idx.publishFileMtimes() diff --git a/internal/indexer/mutation_resolve_mutex_wait.go b/internal/indexer/mutation_resolve_mutex_wait.go deleted file mode 100644 index d01499e7b..000000000 --- a/internal/indexer/mutation_resolve_mutex_wait.go +++ /dev/null @@ -1,110 +0,0 @@ -package indexer - -import "time" - -// EditResolveMutexWait is a diagnostic subset of resolve, not another disjoint -// patch lap. Milliseconds sums the observed acquisitions of this execution. -type EditResolveMutexWait struct { - Milliseconds *float64 `json:"milliseconds"` - State string `json:"state"` - SubsetOf string `json:"subset_of"` - Acquisitions uint64 `json:"acquisitions"` -} - -type mutationResolveMutexWait struct { - total time.Duration - acquisitions uint64 - first, last time.Time - spanStarted, spanLast time.Time - invalid bool -} - -type mutationResolveMutexEvidence struct { - totalNS, firstNS, lastNS int64 - acquisitions uint64 - invalid, incomplete bool -} - -// Only the existing canonical physical P identity may own this diagnostic. -// The separate read-repair collector intentionally has no such ticket/origin. -func (s *PublicationStamps) mutationResolveMutexObserver() func(time.Time, time.Time) { - if s == nil || s.patchLaps == nil || s.patchLaps.ticket == 0 || s.patchLaps.origin.IsZero() { - return nil - } - return s.observeMutationResolveMutexWait -} - -func (s *PublicationStamps) observeMutationResolveMutexWait(started, acquired time.Time) { - s.mu.Lock() - defer s.mu.Unlock() - p := s.patchLaps - d := &p.resolveMutexWait - if d.acquisitions == ^uint64(0) { - d.invalid = true - return - } - d.acquisitions++ - if p.active != 1 || p.activeKind != patchResolve || started.IsZero() || acquired.IsZero() || acquired.Before(started) || started.Before(p.activeStarted) { - d.invalid = true - return - } - elapsed := acquired.Sub(started) - if elapsed < 0 || d.total > time.Duration(1<<63-1)-elapsed { - d.invalid = true - return - } - d.total += elapsed - if d.first.IsZero() || started.Before(d.first) { - d.first = started - } - if acquired.After(d.last) { - d.last = acquired - } - if d.spanStarted.IsZero() { - d.spanStarted = p.activeStarted - } - if d.spanStarted != p.activeStarted { - d.invalid = true - } - if acquired.After(d.spanLast) { - d.spanLast = acquired - } -} - -// Validate each span separately; a later resolve span must not make an -// acquisition in an intervening bookkeeping gap look enclosed. -func (d *mutationResolveMutexWait) finishResolveSpan(started, ended time.Time) { - if !d.spanStarted.IsZero() && (d.spanStarted != started || d.spanLast.After(ended)) { - d.invalid = true - } - d.spanStarted, d.spanLast = time.Time{}, time.Time{} -} - -func (d mutationResolveMutexWait) freeze(origin time.Time) mutationResolveMutexEvidence { - out := mutationResolveMutexEvidence{totalNS: int64(d.total), acquisitions: d.acquisitions, invalid: d.invalid, incomplete: !d.spanStarted.IsZero()} - if !d.first.IsZero() { - out.firstNS = d.first.Sub(origin).Nanoseconds() - out.lastNS = d.last.Sub(origin).Nanoseconds() - } - return out -} - -func (d mutationResolveMutexEvidence) project(resolve canonicalLapEvidence, enclosingInvalid bool) *EditResolveMutexWait { - if d.acquisitions == 0 && !d.invalid { - return nil - } - out := &EditResolveMutexWait{State: "not_observed", SubsetOf: "resolve", Acquisitions: d.acquisitions} - if d.invalid || enclosingInvalid || d.firstNS < resolve.firstNS || d.lastNS > resolve.lastNS || d.totalNS < 0 { - out.State = "invalid_order" - } else if !d.incomplete && resolve.begun > 0 && resolve.begun == resolve.done && !resolve.incomplete { - ms := float64(d.totalNS) / 1e6 - out.Milliseconds, out.State = &ms, "measured" - } - return out -} - -// Keep the original P evidence equality: diagnostic subsets never establish -// or invalidate the disjoint accounting witness. -func (p canonicalPatchEvidence) sameAccounting(other canonicalPatchEvidence) bool { - return p.laps == other.laps && p.invalid == other.invalid -} diff --git a/internal/indexer/mutation_resolve_mutex_wait_test.go b/internal/indexer/mutation_resolve_mutex_wait_test.go deleted file mode 100644 index 517ba4d68..000000000 --- a/internal/indexer/mutation_resolve_mutex_wait_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package indexer - -import ( - "encoding/json" - "path/filepath" - "runtime" - "testing" - "time" -) - -func mutationMutexFixture(t *testing.T) (*PublicationPhaseRecord, *PublicationStamps, time.Time) { - t.Helper() - record, _, origin := scalarPatchFixture(t) - // Bind through the production collector factory, never a synthetic R owner. - stamps := record.newCanonicalPatchStamps() - stamps.markAt(PublicationPatchExecutionStarted, origin.Add(2*time.Millisecond)) - return record, stamps, origin -} - -func TestMutationResolveMutexSubsetAggregatesWithoutChangingPatchLaps(t *testing.T) { - r, s, origin := mutationMutexFixture(t) - at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } - span := s.beginPatchLapAt(patchResolve, at(3)) - before := s.patchLaps.laps - observe := s.mutationResolveMutexObserver() - observe(at(4), at(6)) - observe(at(7), at(8)) - if before != s.patchLaps.laps { - t.Fatal("diagnostic was added to disjoint patch laps") - } - span.endAt(at(12)) - span = s.beginPatchLapAt(patchResolve, at(13)) - observe(at(14), at(14)) // A real zero acquisition remains measured. - span.endAt(at(18)) - s.markAt(PublicationPatchExecutionFinished, at(20)) - r.Absorb(s) - r.MarkAt(PublicationTicketCompleted, at(21)) - snapshot := r.Snapshot() - out := snapshot.EditTimings() - d := out.Window.ResolveMutexWait - if d == nil || d.State != "measured" || d.SubsetOf != "resolve" || d.Acquisitions != 3 || d.Milliseconds == nil || *d.Milliseconds != 3 { - t.Fatalf("mutation diagnostic lost: %+v", d) - } - if out.Milliseconds["resolve"] == nil || *out.Milliseconds["resolve"] != 14 { - t.Fatal("diagnostic changed enclosing resolve duration") - } - if _, present := out.Milliseconds["resolve_mutex_wait"]; present { - t.Fatal("diagnostic entered phases_ms") - } - encoded, err := json.Marshal(out) - if err != nil { - t.Fatal(err) - } - var wire map[string]any - if err = json.Unmarshal(encoded, &wire); err != nil { - t.Fatal(err) - } - diagnostic := wire["phase_timing"].(map[string]any)["resolve_mutex_wait"].(map[string]any) - if diagnostic["subset_of"] != "resolve" || diagnostic["acquisitions"] != float64(3) { - t.Fatal("wrong wire contract") - } - s.observeMutationResolveMutexWait(at(30), at(31)) - if r.Snapshot().EditTimings().Window.ResolveMutexWait.Acquisitions != 3 { - t.Fatal("later collector mutated published diagnostic") - } -} - -func TestMutationResolveMutexSubsetRejectsWrongSpanWithoutPoisoningAccounting(t *testing.T) { - for _, kind := range []string{"before", "after", "backwards", "wrong_lap"} { - t.Run(kind, func(t *testing.T) { - r, s, origin := mutationMutexFixture(t) - at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } - lap := patchResolve - if kind == "wrong_lap" { - lap = patchParse - } - span := s.beginPatchLapAt(lap, at(3)) - a, b := 4, 5 - switch kind { - case "before": - a = 2 - case "after": - b = 10 - case "backwards": - b = 3 - } - s.mutationResolveMutexObserver()(at(a), at(b)) - span.endAt(at(7)) - // A later span cannot legitimize the earlier acquisition's out-of-span end. - scalarSpan(s, patchResolve, origin, 11*time.Millisecond, 15*time.Millisecond) - s.markAt(PublicationPatchExecutionFinished, at(16)) - r.Absorb(s) - r.MarkAt(PublicationTicketCompleted, at(17)) - out := r.Snapshot().EditTimings() - d := out.Window.ResolveMutexWait - if d == nil || d.State != "invalid_order" || d.Milliseconds != nil || d.Acquisitions != 1 { - t.Fatalf("invalid diagnostic accepted: %+v", d) - } - if out.States["resolve"] != "measured" { - t.Fatal("diagnostic poisoned disjoint accounting") - } - }) - } -} - -func TestMutationResolveMutexSubsetNilAndZeroCompatibility(t *testing.T) { - var absent *PublicationStamps - if absent.mutationResolveMutexObserver() != nil || (&PublicationStamps{}).mutationResolveMutexObserver() != nil || (&PublicationStamps{patchLaps: &canonicalPatchLaps{}}).mutationResolveMutexObserver() != nil { - t.Fatal("unobserved execution allocated callback") - } - r, s, origin := mutationMutexFixture(t) - at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } - span := s.beginPatchLapAt(patchResolve, at(3)) - span.endAt(at(7)) - s.markAt(PublicationPatchExecutionFinished, at(8)) - r.Absorb(s) - r.MarkAt(PublicationTicketCompleted, at(9)) - if r.Snapshot().EditTimings().Window.ResolveMutexWait != nil { - t.Fatal("missing observer became measured zero") - } - r, s, origin = mutationMutexFixture(t) - span = s.beginPatchLapAt(patchResolve, at(3)) - s.mutationResolveMutexObserver()(at(4), at(4)) - span.endAt(at(7)) - s.markAt(PublicationPatchExecutionFinished, at(8)) - r.Absorb(s) - r.MarkAt(PublicationTicketCompleted, at(9)) - d := r.Snapshot().EditTimings().Window.ResolveMutexWait - if d == nil || d.State != "measured" || d.Milliseconds == nil || *d.Milliseconds != 0 || d.Acquisitions != 1 { - t.Fatalf("actualzero lost: %+v", d) - } -} - -func TestMutationResolveMutexSubsetStaysWithPhysicalCoalescedWinner(t *testing.T) { - origin := time.Now() - at := func(ms int) time.Time { return origin.Add(time.Duration(ms) * time.Millisecond) } - recorder := NewPublicationPhaseRecorder(8, 1) - defer runtime.KeepAlive(recorder) - first := recorder.Begin("", "first", PublicationSourceMCPEdit, origin) - second := recorder.Begin("", "second", PublicationSourceMCPEdit, origin) - if !first.bindCanonicalTicket(1, origin) || !second.bindCanonicalTicket(2, origin) { - t.Fatal("ticket binding failed") - } - early := first.Snapshot() - winner := first.beginCoalescedAttempt(3, 0, at(1)) - if winner == nil { - t.Fatal("no physical winner") - } - stamps := winner.newCanonicalPatchStamps() - stamps.markAt(PublicationPatchExecutionStarted, at(2)) - span := stamps.beginPatchLapAt(patchResolve, at(3)) - stamps.mutationResolveMutexObserver()(at(4), at(6)) - span.endAt(at(7)) - stamps.markAt(PublicationPatchExecutionFinished, at(8)) - winner.Absorb(stamps) - winner.MarkAt(PublicationTicketCompleted, at(9)) - first.linkCoalescedWinner(winner, 1, 3) - second.linkCoalescedWinner(winner, 2, 3) - a, b := first.CoalescedPublication(), second.CoalescedPublication() - if a == nil || b == nil || a.Publication.Key != b.Publication.Key || a.AppliedGeneration != 3 || b.AppliedGeneration != 3 { - t.Fatal("physical owner linkage changed") - } - if early.EditTimings().Window.ResolveMutexWait != nil || first.Snapshot().EditTimings().Window.ResolveMutexWait != nil || second.Snapshot().EditTimings().Window.ResolveMutexWait != nil { - t.Fatal("coalesced receipt borrowed physical mutex evidence") - } - d := a.Timings.Window.ResolveMutexWait - if d == nil || d.State != "measured" || d.Acquisitions != 1 || d.Milliseconds == nil || *d.Milliseconds != 2 { - t.Fatalf("winner lost diagnostic: %+v", d) - } - if b.Timings.Window.ResolveMutexWait.Acquisitions != d.Acquisitions { - t.Fatal("shared execution invented another acquisition") - } - stamps.observeMutationResolveMutexWait(at(10), at(11)) - if a.Timings.Window.ResolveMutexWait.State != "measured" || winner.Snapshot().EditTimings().Window.ResolveMutexWait.Acquisitions != 1 { - t.Fatal("later observer mutated physical snapshot") - } -} - -func TestMutationResolveMutexSubsetActualPointExecutors(t *testing.T) { - for _, multi := range []bool{false, true} { - t.Run(map[bool]string{false: "single", true: "multi"}[multi], func(t *testing.T) { - record := newPointPublication(t, "actual-mutex") - if !record.bindCanonicalTicket(1, time.Now()) { - t.Fatal("ticket binding failed") - } - stamps := record.newCanonicalPatchStamps() - var execute func() error - if multi { - mi, _, roots := repoTopologyFixture(t) - path := filepath.Join(roots["app"], "main.go") - writeFile(t, path, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Added() { Run() }\n") - execute = func() error { _, err := mi.incrementalPointRepoRawObserved("app", path, stamps); return err } - } else { - root, idx, w := inertTestWatcher(t, "edit.go", "package p\nfunc Callee() {}\n") - w.degradedNoFsnotify = true - t.Cleanup(func() { _ = w.Stop() }) - path := filepath.Join(root, "edit.go") - bumpMtime(t, path, "package p\nfunc Callee() {}\nfunc Added() { Callee() }\n") - execute = func() error { _, err := idx.incrementalPointWatcherPathObserved(root, path, stamps); return err } - } - stamps.markAt(PublicationPatchExecutionStarted, time.Now()) - if err := execute(); err != nil { - t.Fatal(err) - } - stamps.markAt(PublicationPatchExecutionFinished, time.Now()) - record.Absorb(stamps) - record.Mark(PublicationTicketCompleted) - out := record.Snapshot().EditTimings() - d := out.Window.ResolveMutexWait - minimum := uint64(1) - if multi { - minimum = 2 - } - if d == nil || d.State != "measured" || d.Milliseconds == nil || d.Acquisitions < minimum { - t.Fatalf("actual executor did not retain scoped acquisitions: %+v resolve=%s", d, out.States["resolve"]) - } - if out.Milliseconds["resolve"] == nil || *d.Milliseconds > *out.Milliseconds["resolve"] { - t.Fatal("sequential mutex subset exceeded enclosing resolve wall") - } - }) - } -} - -func TestMutationResolveMutexDiagnosticCannotChangePhysicalAccounting(t *testing.T) { - for _, change := range []string{"diagnostic_only", "lap_changed", "invalid_changed"} { - t.Run(change, func(t *testing.T) { - stamps, origin, _ := completeControlledAttempt(t) - recorder := NewPublicationPhaseRecorder(2, 1) - defer runtime.KeepAlive(recorder) - record := recorder.Begin("", "accounting-owner", PublicationSourceMCPEdit, origin) - if !record.bindCanonicalTicket(1, origin) { - t.Fatal("binding failed") - } - record.Absorb(stamps) - record.MarkAt(PublicationTicketCompleted, origin.Add(32*time.Millisecond)) - switch change { - case "diagnostic_only": - stamps.observeMutationResolveMutexWait(origin.Add(20*time.Millisecond), origin.Add(21*time.Millisecond)) - case "lap_changed": - stamps.patchLaps.laps[patchResolve].total += time.Nanosecond - case "invalid_changed": - stamps.patchLaps.invalid = true - } - window, ok := record.completePhysicalAttempt(stamps, origin.Add(time.Millisecond), origin.Add(31*time.Millisecond), 0) - if !ok { - t.Fatal("physical owner was rejected") - } - if window.accounting.invalid != (change != "diagnostic_only") { - t.Fatalf("diagnostic isolation weakened canonical identity: %s invalid=%t", change, window.accounting.invalid) - } - if record.Snapshot().EditTimings().Window.ResolveMutexWait != nil { - t.Fatal("late diagnostic rewrote the frozen publication snapshot") - } - }) - } -} diff --git a/internal/indexer/repo_topology_gates_test.go b/internal/indexer/repo_topology_gates_test.go deleted file mode 100644 index 9b5899c26..000000000 --- a/internal/indexer/repo_topology_gates_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package indexer - -import ( - "fmt" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/zzet/gortex/internal/graph" - "github.com/zzet/gortex/internal/reach" -) - -// awaitRepoCoupling waits until the store's repository coupling is read, so a -// patch of lib or app no longer holds the whole graph. -func awaitRepoCoupling(t *testing.T, g graph.Store) { - t.Helper() - require.Eventually(t, func() bool { - finishLib := reach.BeginRepoTopologyMutation(g, "lib") - defer finishLib(false, nil) - admitted := make(chan struct{}) - go func() { - reach.BeginRepoTopologyMutation(g, "app")(false, nil) - close(admitted) - }() - select { - case <-admitted: - return true - case <-time.After(10 * time.Millisecond): - return false - } - }, 5*time.Second, 10*time.Millisecond, "the repository coupling was never read") -} - -// holdRepoPatch starts a patch of one file in prefix and parks it inside its -// topology mutation, after the parse and resolver tails and before the -// derived passes. finish lets the patch complete and returns its error. -func holdRepoPatch(t *testing.T, mi *MultiIndexer, prefix, path, content string) (finish func() error) { - t.Helper() - entered, release := make(chan struct{}), make(chan struct{}) - var once sync.Once - mi.GetIndexer(prefix).incrementalCatchupHook = func(kind string, _ []string) { - if kind == "derived" { - once.Do(func() { - close(entered) - <-release - }) - } - } - writeFile(t, path, content) - done := make(chan error, 1) - go func() { - _, err := mi.IncrementalReindexRepo(prefix, []string{path}) - done <- err - }() - select { - case <-entered: - case err := <-done: - t.Fatalf("patch of %s finished without reaching its derived tail: %v", prefix, err) - case <-time.After(30 * time.Second): - t.Fatalf("patch of %s never reached its derived tail", prefix) - } - var ( - released sync.Once - err error - ) - finish = func() error { - released.Do(func() { - close(release) - err = <-done - }) - return err - } - t.Cleanup(func() { _ = finish() }) - return finish -} - -func TestTopologyGatePerRepoAllowsParallelPatches(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - awaitRepoCoupling(t, g) - finishApp := holdRepoPatch(t, mi, "app", filepath.Join(roots["app"], "main.go"), - "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") - - lib := filepath.Join(roots["lib"], "lib.go") - writeFile(t, lib, "package library\nfunc Hello() {}\nfunc Local() { Hello() }\nfunc More() { Local() }\n") - done := make(chan error, 1) - go func() { - _, err := mi.IncrementalReindexRepo("lib", []string{lib}) - done <- err - }() - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("a patch of lib waited behind an in-flight patch of app") - } - require.NoError(t, finishApp()) -} - -func TestLookupNotStarvedByOtherRepoWriters(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - awaitRepoCoupling(t, g) - finishApp := holdRepoPatch(t, mi, "app", filepath.Join(roots["app"], "main.go"), - "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") - - started := time.Now() - d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") - elapsed := time.Since(started) - require.True(t, hit) - require.False(t, truncated, "impact on lib degraded to a lower bound after %s behind a patch of app", elapsed) - require.Less(t, elapsed, time.Second) - require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) - require.NoError(t, finishApp()) -} - -func TestForeignRepoEditKeepsReachRecordsHot(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - awaitRepoCoupling(t, g) - reach.BuildIndex(g) - - main := filepath.Join(roots["app"], "main.go") - writeFile(t, main, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") - _, err := mi.IncrementalReindexRepo("app", []string{main}) - require.NoError(t, err) - - _, _, _, hit, _ := reach.LookupCached(g, "app/main.go::Run") - require.False(t, hit, "control: the edited repository's records must be retired") - d1, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") - require.True(t, hit, "an edit in app retired the records of lib") - require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) -} - -// A patch whose resolver pass binds a reference, not a call, from app into -// lib joins the two repositories all the same: the records of lib must not be -// served after it. The resolver hook binds the edge the way a name-based -// resolver pass does, outside the cross-repository candidate pass. -func TestRepoPatchBindingAReferenceRetiresTheOtherRepo(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - awaitRepoCoupling(t, g) - reach.BuildIndex(g) - _, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") - require.True(t, hit, "fixture must start from a published record") - - app := mi.GetIndexer("app") - resolved := false - app.incrementalResolveFilesHook = func(frontier []string) { - mi.runMasterResolveFilesWithEvidence(frontier, false, app.resolver) - g.AddBatch(nil, []*graph.Edge{{ - From: "app/main.go::Extra", To: "lib/lib.go::Hello", Kind: graph.EdgeReferences, - FilePath: "app/main.go", Line: 4, - }}) - resolved = true - } - main := filepath.Join(roots["app"], "main.go") - writeFile(t, main, "package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc Extra() { Run() }\n") - _, err := mi.IncrementalReindexRepo("app", []string{main}) - require.NoError(t, err) - require.True(t, resolved, "the patch must run its resolver pass") - var bound bool - for _, e := range g.GetInEdges("lib/lib.go::Hello") { - bound = bound || e.From == "app/main.go::Extra" && e.Kind == graph.EdgeReferences - } - require.True(t, bound, "the reference must survive the patch's later passes") - - _, _, _, hit, _ = reach.LookupCached(g, "lib/lib.go::Hello") - require.False(t, hit, "a reference bound from app into lib left the records of lib current") -} - -// Patches of independent repositories run their resolver and derived tails -// concurrently. Exercise their shared state together with impact lookups. -func TestConcurrentRepoPatchTails(t *testing.T) { - for _, coupled := range []bool{false, true} { - t.Run(fmt.Sprintf("coupled=%v", coupled), func(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - awaitRepoCoupling(t, g) - var wg sync.WaitGroup - patch := func(prefix, file string, body func(i int) string) { - defer wg.Done() - for i := range 12 { - writeFile(t, file, body(i)) - if _, err := mi.IncrementalReindexRepo(prefix, []string{file}); err != nil { - t.Error(err) - return - } - } - } - wg.Add(3) - go patch("lib", filepath.Join(roots["lib"], "lib.go"), func(i int) string { - var b strings.Builder - b.WriteString("package library\nfunc Hello() {}\nfunc Local() { Hello() }\n") - for j := range i % 4 { - fmt.Fprintf(&b, "func Extra%d() { Local() }\n", j) - } - return b.String() - }) - go patch("app", filepath.Join(roots["app"], "main.go"), func(i int) string { - if coupled && i%2 == 1 { - return fmt.Sprintf("package app\nimport library \"example.org/library\"\nfunc Run() {}\nfunc Start() { Run(); library.Hello() }\nfunc V%d() {}\n", i) - } - return fmt.Sprintf("package app\nfunc Run() {}\nfunc Start() { Run() }\nfunc V%d() { Start() }\n", i) - }) - go func() { - defer wg.Done() - for range 60 { - reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") - reach.LookupContext(t.Context(), g, "app/main.go::Run") - } - }() - wg.Wait() - d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Local") - if !hit || truncated { - t.Fatalf("final lookup hit=%v truncated=%v", hit, truncated) - } - if got := reachIDs(d1); len(got) != 3 { - t.Fatalf("callers of Local after the last patch = %v, want Extra0..Extra2", got) - } - }) - } -} diff --git a/internal/indexer/repo_topology_test.go b/internal/indexer/repo_topology_test.go deleted file mode 100644 index 4a9726c7d..000000000 --- a/internal/indexer/repo_topology_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package indexer - -import ( - "context" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/zzet/gortex/internal/config" - "github.com/zzet/gortex/internal/graph" - "github.com/zzet/gortex/internal/graph/store_sqlite" - "github.com/zzet/gortex/internal/reach" - "github.com/zzet/gortex/internal/search" -) - -// repoTopologyFixture tracks two Go repositories in one SQLite store with no -// edge between them: "lib" (module example.org/library) and "app". -func repoTopologyFixture(t *testing.T) (*MultiIndexer, graph.Store, map[string]string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - lib, app := t.TempDir(), t.TempDir() - writeFile(t, filepath.Join(lib, "go.mod"), "module example.org/library\n") - writeFile(t, filepath.Join(lib, "lib.go"), "package library\nfunc Hello() {}\nfunc Local() { Hello() }\n") - writeFile(t, filepath.Join(app, "go.mod"), "module example.org/app\n") - writeFile(t, filepath.Join(app, "main.go"), "package app\nfunc Run() {}\nfunc Start() { Run() }\n") - - s, err := store_sqlite.Open(filepath.Join(t.TempDir(), "graph.sqlite")) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, s.Close()) }) - mi := NewMultiIndexer(s, newTestRegistry(), search.NewNull(), newTestConfigManager(t), zap.NewNop()) - t.Cleanup(func() { require.NoError(t, mi.Close(context.Background())) }) - for _, entry := range []config.RepoEntry{ - {Path: lib, Name: "lib", Workspace: "shared"}, - {Path: app, Name: "app", Workspace: "shared"}, - } { - _, err := mi.TrackRepoCtx(t.Context(), entry) - require.NoError(t, err) - } - return mi, s, map[string]string{"lib": lib, "app": app} -} - -func reachIDs(entries []reach.Entry) []string { - ids := make([]string, 0, len(entries)) - for _, e := range entries { - ids = append(ids, e.ID) - } - return ids -} - -// A patch in one repository that adds an edge into another must retire the -// other repository's reach records too, or impact keeps answering from a -// record that misses the new caller. -func TestCrossRepoPatchInvalidatesBothRepos(t *testing.T) { - mi, g, roots := repoTopologyFixture(t) - reach.BuildIndex(g) - d1, _, _, hit, _ := reach.LookupCached(g, "lib/lib.go::Hello") - require.True(t, hit, "fixture must start from a published record") - require.Equal(t, []string{"lib/lib.go::Local"}, reachIDs(d1)) - - main := filepath.Join(roots["app"], "main.go") - writeFile(t, main, "package app\nimport library \"example.org/library\"\nfunc Run() {}\nfunc Start() { Run(); library.Hello() }\n") - _, err := mi.IncrementalReindexRepo("app", []string{main}) - require.NoError(t, err) - var bound bool - for _, e := range g.GetOutEdges("app/main.go::Start") { - bound = bound || e.To == "lib/lib.go::Hello" - } - require.True(t, bound, "the patch must bind the cross-repository call") - - d1, _, _, hit, truncated := reach.LookupContext(t.Context(), g, "lib/lib.go::Hello") - require.True(t, hit) - require.False(t, truncated) - require.ElementsMatch(t, []string{"lib/lib.go::Local", "app/main.go::Start"}, reachIDs(d1), - "the record published before the patch was served") -} diff --git a/internal/indexer/workspace_resolve.go b/internal/indexer/workspace_resolve.go index 9aeb82001..1a88aac47 100644 --- a/internal/indexer/workspace_resolve.go +++ b/internal/indexer/workspace_resolve.go @@ -6,7 +6,6 @@ import ( "sort" "sync" "sync/atomic" - "time" "go.uber.org/zap" @@ -543,9 +542,8 @@ func (mi *MultiIndexer) runCrossRepoResolveContext(ctx context.Context, reconcil // runCrossRepoResolveFiles binds both outgoing and incoming unresolved edges // for one coordinated mutation frontier. Its caller owns the repository lane -// and the topology-writer gates of every repository the mutation can affect, so -// the graph cannot expose a half-resolved batch between same-repo, semantic, -// and cross-repository phases. +// and global topology-writer gate, so the graph cannot expose a half-resolved +// batch between same-repo, semantic, and cross-repository phases. func (mi *MultiIndexer) runCrossRepoResolveFiles(filePaths []string) { mi.runCrossRepoResolveMutationFiles(filePaths, filePaths) } @@ -555,10 +553,6 @@ func (mi *MultiIndexer) runCrossRepoResolveMutationFiles(resolutionFiles, materi } func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiers(resolutionFiles, edgeSourceFiles, definitionFiles []string) { - mi.runCrossRepoResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles, nil) -} - -func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) { if len(resolutionFiles) == 0 && len(edgeSourceFiles) == 0 && len(definitionFiles) == 0 { return } @@ -566,7 +560,7 @@ func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiersObserved(resolutionF if cr == nil { return } - cr.ResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles, observe) + cr.ResolveMutationFrontiers(resolutionFiles, edgeSourceFiles, definitionFiles) mi.finishGoModuleCalls(cr) mi.reconcileRetargetedTestCalls(cr.TakeRetargetedTestCallFiles()) } diff --git a/internal/reach/coordination_wait.go b/internal/reach/coordination_wait.go index ef81ed0c1..fa75936b9 100644 --- a/internal/reach/coordination_wait.go +++ b/internal/reach/coordination_wait.go @@ -2,6 +2,7 @@ package reach import ( "context" + "sync" "time" ) @@ -30,13 +31,26 @@ func coordinationWaitObserver(ctx context.Context) func(CoordinationWait) { return observer } -func observedTopologyRead(ctx context.Context, scope []string) (func(), bool) { +func observedTopologyRead(ctx context.Context) (func(), bool) { observer := coordinationWaitObserver(ctx) if observer == nil { - return beginTopologyRead(ctx, scope) + return beginTopologyReadContext(ctx) } started := time.Now() - release, ok := beginTopologyRead(ctx, scope) + release, ok := beginTopologyReadContext(ctx) observer(CoordinationWait{Phase: "gate_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) return release, ok } + +// observedResolveLock reports the existing mutex admission without changing +// which readers acquire it or the cancellation policy. +func observedResolveLock(ctx context.Context, mu *sync.Mutex) bool { + observer := coordinationWaitObserver(ctx) + if observer == nil { + return lockContext(ctx, mu) + } + started := time.Now() + ok := lockContext(ctx, mu) + observer(CoordinationWait{Phase: "resolve_mutex_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) + return ok +} diff --git a/internal/reach/coordination_wait_test.go b/internal/reach/coordination_wait_test.go index 61a91e08c..70953b8c3 100644 --- a/internal/reach/coordination_wait_test.go +++ b/internal/reach/coordination_wait_test.go @@ -69,27 +69,19 @@ func TestLookupCoordinationWaitKeepsGateAndMutexSeparate(t *testing.T) { } } -func TestLookupCoordinationWaitReportsDetachedMutexAsNotAttempted(t *testing.T) { - store := repoTopologyStore(t) - mu := store.ResolveMutex() - mu.Lock() - defer mu.Unlock() +func TestLookupCoordinationWaitReportsCancelledGlobalGate(t *testing.T) { + g, ids := newCallChain(t, 2) + finish := BeginTopologyMutation(g) + defer finish(false) + ctx, cancel := context.WithCancel(t.Context()) + cancel() var events []CoordinationWait - ctx := WithCoordinationWaitObserver(t.Context(), func(wait CoordinationWait) { events = append(events, wait) }) - _, _, _, hit, truncated := LookupContext(ctx, store, seedA) - if !hit || truncated { - t.Fatalf("detached lookup hit=%v truncated=%v", hit, truncated) + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { events = append(events, wait) }) + _, _, _, _, truncated := LookupContext(ctx, g, ids[1]) + if !truncated { + t.Fatal("cancelled gate admission must retain its conservative result") } - found := false - for _, wait := range events { - if wait.Phase == "resolve_mutex_wait" { - found = true - if wait.Attempted || !wait.Acquired { - t.Fatalf("detached read claimed to acquire the held mutex: %+v", wait) - } - } - } - if !found { - t.Fatal("detached mutex disposition was absent") + if len(events) != 1 || events[0].Phase != "gate_wait" || !events[0].Attempted || events[0].Acquired { + t.Fatalf("cancelled global gate claimed successful or mutex admission: %+v", events) } } diff --git a/internal/reach/global_admission_test.go b/internal/reach/global_admission_test.go new file mode 100644 index 000000000..16eeb298c --- /dev/null +++ b/internal/reach/global_admission_test.go @@ -0,0 +1,39 @@ +package reach + +import ( + "context" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// Before repository-scoped admission is introduced, SQLite lookups use the +// same global gate and resolver mutex as the existing base-topology reader. +// Cancellation must report the actual failed mutex acquisition, not detached +// admission borrowed from the later repository-topology implementation. +func TestGlobalReachAdmissionRetainsSQLiteMutex(t *testing.T) { + store := openTopologyScopeStore(t) + store.AddNode(&graph.Node{ID: "seed", Name: "seed", Kind: graph.KindFunction}) + mu := store.ResolveMutex() + mu.Lock() + defer mu.Unlock() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var waits []CoordinationWait + ctx = WithCoordinationWaitObserver(ctx, func(wait CoordinationWait) { + waits = append(waits, wait) + }) + _, _, _, _, truncated := LookupContext(ctx, store, "seed") + if !truncated { + t.Fatal("cancelled lookup must retain its conservative result") + } + for _, wait := range waits { + if wait.Phase == "resolve_mutex_wait" { + if !wait.Attempted || wait.Acquired { + t.Fatalf("held SQLite mutex must report a cancelled acquisition: %+v", wait) + } + return + } + } + t.Fatalf("actual SQLite mutex admission was not observed: %+v", waits) +} diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 9e6aa6aa5..7cd520545 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -123,14 +123,13 @@ type Stats struct { EntriesD1 int // total reach_d1 IDs across all indexed nodes EntriesD2 int // total reach_d2 IDs EntriesD3 int // total reach_d3 IDs - Build uint64 // generation the build read the graph at + Build uint64 // generation tag stamped on every indexed node } -// buildCounter is the process-wide source of reach generations: every -// invalidation, of the whole graph or of some repositories, takes its next -// value, so a generation never repeats within a process. reachProcessEpoch -// prevents a counter value reused by a restarted process from matching a -// record persisted by the previous process. +// buildCounter is a process-wide monotonic generation counter used to +// invalidate cached reach sets after graph mutations and incremental rebuilds. +// reachProcessEpoch prevents a counter value reused by a restarted process from +// matching a record persisted by the previous process. var ( buildCounter uint64 reachProcessEpoch = newReachProcessEpoch() @@ -146,214 +145,81 @@ func newReachProcessEpoch() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } -// topologyGate is the writer-preferring reader/writer state of one scope: the -// whole graph, or one repository prefix. +// topologyGate serialises impact readers/eager reach maintenance with a +// watcher topology transaction. It is separate from Store.ResolveMutex: +// watcher indexing calls resolver methods that acquire ResolveMutex +// internally, so holding that non-reentrant mutex around the whole patch would +// deadlock. Readers always acquire this gate before ResolveMutex; a watcher +// holds the writer side while its nested resolver calls take ResolveMutex. type topologyGate struct { + mu sync.Mutex + cond *sync.Cond readers int - writers int + writer bool writersWaiting int } -// topologyState serialises impact readers and eager reach maintenance with -// watcher topology transactions, per repository. It is separate from -// Store.ResolveMutex: watcher indexing calls resolver methods that acquire -// ResolveMutex internally, so holding that non-reentrant mutex around the -// whole patch would deadlock. Readers always acquire a gate before -// ResolveMutex; a watcher holds the writer side while its nested resolver -// calls take ResolveMutex. -// -// A scope is nil for the whole graph, or a sorted list of repository -// prefixes. One mutex guards every gate, so a mutation takes all of its gates -// in one step and gates need no lock order among themselves. Waiters sleep on -// wake, which is allocated only while callers are waiting and cleared on -// broadcast or when its last waiter leaves. In particular, an idle state must -// not retain a channel owned by a caller's synctest bubble. -// -// The state is process-global, like buildCounter. Repository gates are created -// on first use and dropped once idle, so the map holds only work in flight. -// Production multi-repo indexers share one Store; tests that reuse a prefix -// across stores only over-serialise. -type topologyState struct { - mu sync.Mutex - wake chan struct{} - wakeWaitersCount int // Waiters of wake only; broadcast generations retire independently. - all topologyGate - repos map[string]*topologyGate - // Readers, writers and waiting writers of repository scopes, so that - // whole-graph admission need not scan repos. - repoReaders, repoWriters, repoWritersWaiting int - - // allBuild is the generation of the last whole-graph invalidation, and - // repoBuild the generation of each repository invalidated since. - allBuild uint64 - repoBuild map[string]uint64 +// buildCounter is already process-global, so topology publication uses one +// process-global gate as well. This avoids retaining one registry entry per +// short-lived Store forever (notably across tests and workspace reloads). +// Production multi-repo indexers share one Store, and the conservative +// cross-store serialization is preferable to an unbounded coordination +// registry — but only while what passes through it stays scarce. Every +// checkout coordinator, ref view and warmup tail in the daemon queues on this +// one gate, so a pass that cannot change what the records describe must not +// enter it at all: see WritesBaseTopology. +var globalTopologyGate = func() *topologyGate { + gate := &topologyGate{} + gate.cond = sync.NewCond(&gate.mu) + return gate +}() + +func beginTopologyRead(_ graph.Store) func() { + gate := globalTopologyGate + gate.mu.Lock() + for gate.writer || gate.writersWaiting > 0 { + gate.cond.Wait() + } + gate.readers++ + gate.mu.Unlock() - coupling repoCoupling -} - -var topology = &topologyState{ - repos: make(map[string]*topologyGate), - repoBuild: make(map[string]uint64), -} - -// readable reports whether a reader of scope may enter: no writer holds or -// awaits any gate the reader needs. t.mu is held. -func (t *topologyState) readable(scope []string) bool { - if t.all.writers > 0 || t.all.writersWaiting > 0 { - return false - } - if scope == nil { - return t.repoWriters == 0 && t.repoWritersWaiting == 0 - } - for _, prefix := range scope { - if gate := t.repos[prefix]; gate != nil && (gate.writers > 0 || gate.writersWaiting > 0) { - return false - } - } - return true -} - -// writable reports whether a writer of scope may enter. A repository writer -// also yields to a waiting whole-graph writer so the latter cannot starve. -// t.mu is held. -func (t *topologyState) writable(scope []string) bool { - if t.all.writers > 0 || t.all.readers > 0 { - return false - } - if scope == nil { - return t.repoReaders == 0 && t.repoWriters == 0 - } - if t.all.writersWaiting > 0 { - return false - } - for _, prefix := range scope { - if gate := t.repos[prefix]; gate != nil && (gate.writers > 0 || gate.readers > 0) { - return false - } - } - return true -} - -// add moves every gate of scope by the given deltas. t.mu is held. -func (t *topologyState) add(scope []string, readers, writers, waiting int) { - if scope == nil { - t.all.readers += readers - t.all.writers += writers - t.all.writersWaiting += waiting - return - } - t.repoReaders += readers - t.repoWriters += writers - t.repoWritersWaiting += waiting - for _, prefix := range scope { - gate := t.repos[prefix] - if gate == nil { - gate = &topologyGate{} - t.repos[prefix] = gate - } - gate.readers += readers - gate.writers += writers - gate.writersWaiting += waiting - if *gate == (topologyGate{}) { - delete(t.repos, prefix) + return func() { + gate.mu.Lock() + gate.readers-- + if gate.readers == 0 { + gate.cond.Broadcast() } + gate.mu.Unlock() } } -// wait sleeps until a holder leaves and reports false when ctx ends first. -// t.mu is held on entry and on return. -func (t *topologyState) wait(ctx context.Context) bool { - if t.wake == nil { - t.wake = make(chan struct{}) - } - wake := t.wake - t.wakeWaitersCount++ - t.mu.Unlock() - defer func() { - t.mu.Lock() - if t.wake == wake { - t.wakeWaitersCount-- - if t.wakeWaitersCount == 0 { - t.wake = nil - } +// beginTopologyReadContext is the cancellable counterpart used by lazy +// impact lookups. It never waits on sync.Cond (which has no cancellation +// channel); a short poll keeps the writer-preference semantics while allowing +// an expired tool request to stop waiting. +func beginTopologyReadContext(ctx context.Context) (func(), bool) { + gate := globalTopologyGate + for { + gate.mu.Lock() + if !gate.writer && gate.writersWaiting == 0 { + gate.readers++ + gate.mu.Unlock() + return func() { + gate.mu.Lock() + gate.readers-- + if gate.readers == 0 { + gate.cond.Broadcast() + } + gate.mu.Unlock() + }, true } - }() - select { - case <-wake: - return true - case <-ctx.Done(): - return false - } -} - -// wakeWaiters lets every waiter re-check its admission. t.mu is held. A -// replacement is created by the next actual waiter, never by the releaser. -func (t *topologyState) wakeWaiters() { - if t.wake != nil { - close(t.wake) - t.wake = nil - t.wakeWaitersCount = 0 - } -} - -// retire moves the reach generation of scope past every value issued so far. -// t.mu is held. -func (t *topologyState) retire(scope []string) { - build := atomic.AddUint64(&buildCounter, 1) - if scope == nil { - t.allBuild = build - clear(t.repoBuild) - return - } - for _, prefix := range scope { - t.repoBuild[prefix] = build - } -} - -// retireWhole is retire for the whole graph, taking t.mu. -func retireWhole() { - topology.mu.Lock() - defer topology.mu.Unlock() - topology.retire(nil) -} - -// generation is the build a current record of a seed in scope carries. A seed -// no repository owns follows every invalidation. -func generation(scope []string) uint64 { - if scope == nil { - return atomic.LoadUint64(&buildCounter) - } - t := topology - t.mu.Lock() - defer t.mu.Unlock() - build := t.allBuild - for _, prefix := range scope { - build = max(build, t.repoBuild[prefix]) - } - return build -} - -// beginTopologyRead waits until no writer holds or awaits a gate of scope, or -// until ctx ends. -func beginTopologyRead(ctx context.Context, scope []string) (func(), bool) { - t := topology - t.mu.Lock() - for !t.readable(scope) { - if !t.wait(ctx) { - t.mu.Unlock() + gate.mu.Unlock() + select { + case <-ctx.Done(): return nil, false + case <-time.After(lockPoll): } } - t.add(scope, 1, 0, 0) - t.mu.Unlock() - return func() { - t.mu.Lock() - t.add(scope, -1, 0, 0) - if t.all.writersWaiting > 0 || t.repoWritersWaiting > 0 { - t.wakeWaiters() - } - t.mu.Unlock() - }, true } func lockContext(ctx context.Context, mu *sync.Mutex) bool { @@ -367,39 +233,6 @@ func lockContext(ctx context.Context, mu *sync.Mutex) bool { return true } -// detachedNodeReader is a store whose reads stay consistent without its -// ResolveMutex, so a lookup does not wait behind a resolver or enrichment pass -// that holds it. -type detachedNodeReader interface { - NodeReadsDetached() bool -} - -// lockResolve takes g's ResolveMutex for a read, unless g's reads are detached -// from it. A record write always takes the mutex: it rewrites whole node rows -// that a pass holding the mutex may be rewriting too. -func lockResolve(ctx context.Context, g graph.Store) (unlock func(), ok bool) { - observer := coordinationWaitObserver(ctx) - if detached, isDetached := g.(detachedNodeReader); isDetached && detached.NodeReadsDetached() { - if observer != nil { - observer(CoordinationWait{Phase: "resolve_mutex_wait", Acquired: true}) - } - return func() {}, true - } - var started time.Time - if observer != nil { - started = time.Now() - } - mu := g.ResolveMutex() - ok = lockContext(ctx, mu) - if observer != nil { - observer(CoordinationWait{Phase: "resolve_mutex_wait", Duration: time.Since(started), Attempted: true, Acquired: ok}) - } - if !ok { - return nil, false - } - return mu.Unlock, true -} - // viewGenerationPinned is implemented by a store handle bound to one payload // view generation. A handle that does not report one reads and writes the base // corpus. @@ -430,279 +263,44 @@ func WritesBaseTopology(g graph.Store) bool { } // BeginTopologyMutation prevents reach lookups/builds from observing a -// watcher's parse-then-swap and resolver work half-applied. It holds the whole -// graph: use BeginRepoTopologyMutation when the mutation belongs to one -// repository. The returned function must be called exactly once; changed=true -// invalidates every cached generation, and the repository coupling, before -// waiting readers are released. A separate gate is required because the -// mutation body itself invokes resolver methods that acquire the store's -// non-reentrant ResolveMutex. +// watcher's parse-then-swap and resolver work half-applied. The returned +// function must be called exactly once; changed=true invalidates every cached +// generation before waiting readers are released. A separate gate is required +// because the mutation body itself invokes resolver methods that acquire the +// store's non-reentrant ResolveMutex. // // A mutation that cannot reach the base corpus takes nothing and invalidates -// nothing. The gates are process-global, so a generation-pinned build that held -// them would serialise every unrelated store and pass in the daemon behind a +// nothing. The gate is process-global, so a generation-pinned build that held +// it would serialise every unrelated store and pass in the daemon behind a // pass whose duration is its payload's, not the corpus's. func BeginTopologyMutation(g graph.Store) func(changed bool) { if !WritesBaseTopology(g) { return func(bool) {} } - finish := beginTopologyWrite(g, "") - return func(changed bool) { finish(changed, nil) } -} - -// BeginRepoTopologyMutation is BeginTopologyMutation for a mutation of the -// rows of repository prefix. It waits only for the readers and writers of the -// repositories coupled with prefix. The whole graph is held instead for an -// empty prefix, and while the store's coupling is unknown; the first mutation -// that needs it starts reading it in the background. -// -// The returned function must be called exactly once. frontier lists the files -// the mutation may have added or rebound edges in or at the nodes of. With -// changed=true, the couplings those edges make are learned first, and then the -// records of every repository coupled with prefix are invalidated. A nil -// frontier says edges anywhere may have moved: every record is invalidated and -// the coupling forgotten. -func BeginRepoTopologyMutation(g graph.Store, prefix string) func(changed bool, frontier []string) { - if !WritesBaseTopology(g) { - return func(bool, []string) {} - } - if prefix != "" { - ensureRepoCoupling(g) - } - return beginTopologyWrite(g, prefix) -} - -func beginTopologyWrite(g graph.Store, prefix string) func(changed bool, frontier []string) { - t := topology - t.mu.Lock() - scope, epoch := t.writeScope(g, prefix) - t.add(scope, 0, 0, 1) - for !t.writable(scope) { - t.wait(context.Background()) - if epoch != t.coupling.epoch { - // The coupling the scope came from was retired while this writer - // waited. - t.add(scope, 0, 0, -1) - scope, epoch = t.writeScope(g, prefix) - t.add(scope, 0, 0, 1) - } + gate := globalTopologyGate + gate.mu.Lock() + gate.writersWaiting++ + for gate.writer || gate.readers > 0 { + gate.cond.Wait() } - t.add(scope, 0, 1, -1) - t.mu.Unlock() + gate.writersWaiting-- + gate.writer = true + gate.mu.Unlock() var once sync.Once - return func(changed bool, frontier []string) { + return func(changed bool) { once.Do(func() { - if changed && prefix != "" && frontier != nil { - learnRepoCoupling(g, frontier) - } - t.mu.Lock() if changed { - if prefix == "" || frontier == nil { - // Edges anywhere may have moved. - t.coupling.forget(g) - } - // What the mutation held, widened by the couplings it bound; the - // whole graph when either is unknown. - retired, _ := t.writeScope(g, prefix) - if scope == nil || retired == nil { - retired = nil - } else { - retired = append(retired, scope...) - } - t.retire(retired) + InvalidateIndex() } - t.add(scope, 0, -1, 0) - t.wakeWaiters() - t.mu.Unlock() + gate.mu.Lock() + gate.writer = false + gate.cond.Broadcast() + gate.mu.Unlock() }) } } -// writeScope is the gate set a mutation of prefix holds: the repositories -// coupled with it, or the whole graph (nil) for an empty prefix or while the -// coupling of g is unknown. The epoch tells a waiting writer when to ask -// again. t.mu is held. -func (t *topologyState) writeScope(g graph.Store, prefix string) ([]string, uint64) { - c := &t.coupling - if prefix == "" || !c.ready || c.store != g { - return nil, c.epoch - } - return c.component(prefix), c.epoch -} - -// repoCoupling groups the repositories of one store whose reach records can -// cross: an edge joins them, directly or through a node no repository owns. A -// mutation of one member can change the records of every member of its group. -// -// It is read from the store in the background once per epoch, grows with what -// each repository mutation learns from its frontier, and is forgotten by any -// mutation that may have moved edges anywhere. A store whose read fails is not -// read again. Only one store is tracked; a mutation of another store holds the -// whole graph until it is read again. -type repoCoupling struct { - store graph.Store - epoch uint64 - ready bool - seeding bool - missed bool - // pending holds what mutations learned while the read was in flight. - pending [][2]string - parent map[string]string -} - -// couplingReadBudget bounds each coupling read. A store whose read misses it -// keeps whole-graph mutations until the process restarts. -const couplingReadBudget = 2 * time.Second - -// repoCouplingSource reports the distinct repository pairs the store's edges -// join: all of them, or those at the nodes of some files. -type repoCouplingSource interface { - RepoCouplingPairs(ctx context.Context) ([][2]string, error) - RepoCouplingPairsForFiles(ctx context.Context, files []string) ([][2]string, error) -} - -func (c *repoCoupling) find(prefix string) string { - for { - parent, ok := c.parent[prefix] - if !ok || parent == prefix { - return prefix - } - prefix = parent - } -} - -func (c *repoCoupling) union(a, b string) { - ra, rb := c.find(a), c.find(b) - if ra == rb { - return - } - if c.parent == nil { - c.parent = make(map[string]string) - } - c.parent[ra] = rb - c.parent[rb] = rb - // Queued writers must recompute their gate set when components grow. - c.epoch++ -} - -// component lists prefix and every repository coupled with it, sorted. -func (c *repoCoupling) component(prefix string) []string { - root := c.find(prefix) - scope := []string{prefix} - for member := range c.parent { - if member != prefix && c.find(member) == root { - scope = append(scope, member) - } - } - sort.Strings(scope) - return scope -} - -// reset starts a new epoch for g with nothing known. A store whose read failed -// stays missed. -func (c *repoCoupling) reset(g graph.Store) { - if c.store != g { - c.missed = false - } - c.store, c.epoch = g, c.epoch+1 - c.ready, c.seeding, c.pending, c.parent = false, false, nil, nil -} - -// forget drops what is known about g's coupling. -func (c *repoCoupling) forget(g graph.Store) { - if c.store == g { - c.reset(g) - } -} - -// miss gives up on the current store's coupling for the life of the process. -func (c *repoCoupling) miss() { - c.reset(c.store) - c.missed = true -} - -// ensureRepoCoupling starts reading g's coupling in the background, once per -// epoch. Nothing waits for the read. -func ensureRepoCoupling(g graph.Store) { - source, ok := g.(repoCouplingSource) - if !ok { - return - } - t := topology - t.mu.Lock() - defer t.mu.Unlock() - c := &t.coupling - if c.store != g { - c.reset(g) - } - if c.ready || c.seeding || c.missed { - return - } - c.seeding = true - go readRepoCoupling(g, source, c.epoch) -} - -// readRepoCoupling reads g's coupling for epoch. It takes no gate, so its -// snapshot can miss edges of a mutation that runs beside it; such a mutation -// learns its own couplings into pending, which the read adds on landing. -func readRepoCoupling(g graph.Store, source repoCouplingSource, epoch uint64) { - ctx, cancel := context.WithTimeout(context.Background(), couplingReadBudget) - pairs, err := source.RepoCouplingPairs(ctx) - cancel() - t := topology - t.mu.Lock() - defer t.mu.Unlock() - c := &t.coupling - if c.store != g || c.epoch != epoch { - return - } - if err != nil { - c.miss() - return - } - for _, pair := range append(pairs, c.pending...) { - c.union(pair[0], pair[1]) - } - c.ready, c.seeding, c.pending = true, false, nil -} - -// learnRepoCoupling adds the couplings that the edges at frontier make to g's -// coupling, while it is known or being read. A failed read gives the coupling -// up, since what the mutation bound is then unknown. -func learnRepoCoupling(g graph.Store, frontier []string) { - source, ok := g.(repoCouplingSource) - if !ok || len(frontier) == 0 { - return - } - t := topology - t.mu.Lock() - c := &t.coupling - epoch, wanted := c.epoch, c.store == g && (c.ready || c.seeding) - t.mu.Unlock() - if !wanted { - return - } - ctx, cancel := context.WithTimeout(context.Background(), couplingReadBudget) - pairs, err := source.RepoCouplingPairsForFiles(ctx, frontier) - cancel() - - t.mu.Lock() - defer t.mu.Unlock() - switch { - case c.store != g || c.epoch != epoch: - // Forgotten meanwhile: the next read sees these edges. - case err != nil: - c.miss() - case c.seeding: - c.pending = append(c.pending, pairs...) - default: - for _, pair := range pairs { - c.union(pair[0], pair[1]) - } - } -} - // BuildIndex precomputes per-depth incoming reachability sets for // every impact-seed node in g and stores them under Node.Meta as // []string slices keyed reach_d1 / reach_d2 / reach_d3. Tiers are @@ -716,11 +314,9 @@ func learnRepoCoupling(g graph.Store, frontier []string) { // graph-shaping passes settle (resolver, semantic enrichment, cross- // repo edges, gRPC stub resolution). // -// Safe to call repeatedly: existing reach_d* entries are overwritten. -// Each record carries its repository's generation as of the build, which -// the build does not advance: the records of an unchanged graph are as -// current as their rebuild, and advancing it would make every concurrent -// lookup recompute. +// Safe to call repeatedly: existing reach_d* entries are overwritten +// and the build counter advances each time so any consumer that read +// an entry from a prior generation will fall back to a live walk. func BuildIndex(g graph.Store) *Stats { return BuildIndexCtx(context.Background(), g) } @@ -737,17 +333,17 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { return &Stats{} } reporter := progress.FromContext(ctx) - releaseTopology, ok := beginTopologyRead(ctx, nil) + releaseTopology, ok := beginTopologyReadContext(ctx) if !ok { return &Stats{} } - unlock, ok := lockResolve(ctx, g) - if !ok { + mu := g.ResolveMutex() + if !lockContext(ctx, mu) { releaseTopology() return &Stats{} } - build := atomic.LoadUint64(&buildCounter) - unlock() + build := atomic.AddUint64(&buildCounter, 1) + mu.Unlock() releaseTopology() stats := &Stats{Build: build} @@ -788,12 +384,11 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { } batchCap := min(seedTotal, reachPublishBatchSize) pending := make([]pendingRecord, 0, batchCap) - mu := g.ResolveMutex() publish := func() bool { if len(pending) == 0 { return true } - release, acquired := beginTopologyRead(ctx, nil) + release, acquired := beginTopologyReadContext(ctx) if !acquired { return false } @@ -813,7 +408,7 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { continue } published := cloneNodeWithMeta(n) - writeRecord(published.Meta, generation(nodeScope(n)), record.tiers, record.truncated) + writeRecord(published.Meta, build, record.tiers, record.truncated) stamped = append(stamped, published) } if len(stamped) > 0 { @@ -1106,12 +701,12 @@ func ClearIndex(g graph.Store) { if g == nil { return } - releaseTopology, _ := beginTopologyRead(context.Background(), nil) + releaseTopology := beginTopologyRead(g) defer releaseTopology() mu := g.ResolveMutex() mu.Lock() defer mu.Unlock() - retireWhole() + atomic.AddUint64(&buildCounter, 1) cleared := make([]*graph.Node, 0, reachPublishBatchSize) for _, n := range g.AllNodes() { if n == nil || !hasReachMeta(n.Meta) { @@ -1182,23 +777,22 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca } ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) defer cancel() - scope := seedScope(ctx, g, seedID) - releaseTopology, ok := beginTopologyRead(ctx, scope) + releaseTopology, ok := beginTopologyReadContext(ctx) if !ok { return nil, nil, nil, false, true } - unlock, ok := lockResolve(ctx, g) - if !ok { + mu := g.ResolveMutex() + if !lockContext(ctx, mu) { releaseTopology() return nil, nil, nil, false, true } - build := generation(scope) + build := atomic.LoadUint64(&buildCounter) n := g.GetNode(seedID) if n != nil && ImpactSeedKind(n.Kind) { d1, d2, d3, truncated, hit = readCached(n, build) } - stable := build == generation(scope) - unlock() + stable := build == atomic.LoadUint64(&buildCounter) + mu.Unlock() releaseTopology() if !stable { return nil, nil, nil, false, true @@ -1209,7 +803,7 @@ func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, trunca // LookupContext returns a complete reach record or a bounded lower-bound // record. Expensive BFS work deliberately happens outside topology and // resolver locks. Publication re-enters both locks, verifies the generation, -// and retries once if a mutation crossed the optimistic compute window. This keeps +// and retries if a mutation crossed the optimistic compute window. This keeps // synchronous edits from waiting behind breadth-first graph traversal. type contextNodeGetter interface { GetNodeContext(context.Context, string) (*graph.Node, error) @@ -1252,39 +846,42 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed ctx, cancel := context.WithTimeout(parent, lookupTimeout) defer cancel() - scope := seedScope(ctx, g, seedID) - for retried := false; ; retried = true { - releaseTopology, ok := observedTopologyRead(ctx, scope) + for { + releaseTopology, ok := observedTopologyRead(ctx) if !ok { return nil, nil, nil, false, true } - unlock, ok := lockResolve(ctx, g) - if !ok { + mu := g.ResolveMutex() + if !observedResolveLock(ctx, mu) { releaseTopology() return nil, nil, nil, false, true } - currentBuild := generation(scope) + currentBuild := atomic.LoadUint64(&buildCounter) n, nodeErr := getNodeContext(ctx, g, seedID) if nodeErr != nil { - unlock() + mu.Unlock() releaseTopology() return nil, nil, nil, true, true } if n == nil || !ImpactSeedKind(n.Kind) { - unlock() + mu.Unlock() releaseTopology() return nil, nil, nil, false, false } // A published record describes unfiltered adjacency, so a filtered // lookup always computes. if keep == nil { - if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached && currentBuild == generation(scope) { - unlock() + if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached { + stable := currentBuild == atomic.LoadUint64(&buildCounter) + mu.Unlock() releaseTopology() - return d1, d2, d3, true, cachedTruncated + if stable { + return d1, d2, d3, true, cachedTruncated + } + continue } } - unlock() + mu.Unlock() releaseTopology() // The expensive portion is optimistic and lock-free with respect to @@ -1295,44 +892,20 @@ func lookupContext(parent context.Context, g graph.Store, seedID string, keep Ed // also retaining topology/resolve coordination, starving the daemon. // // BuildIndexCtx is the sole persistent reach-cache writer. A lazy miss - // returns its bounded lower-bound evidence directly. A traversal that a - // topology change crossed is retried once; a second crossing returns - // what it read marked truncated, rather than chasing a repository that - // keeps changing until the deadline. + // returns its bounded lower-bound evidence directly; a concurrent graph + // generation change marks that evidence truncated rather than retrying + // or publishing a mixed snapshot. tiers, traversalTruncated := compute(ctx, g, seedID, keep) if ctx.Err() != nil { return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, true } - if currentBuild != generation(scope) { - if !retried { - continue - } - traversalTruncated = true + if currentBuild != atomic.LoadUint64(&buildCounter) { + continue } return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, traversalTruncated } } -// seedScope is the gate scope a lookup of seedID waits on: the seed's -// repository, or the whole graph for a seed no repository owns or that is -// missing. It only routes the lookup, so it reads the metadata-free projection -// outside any gate; the lookup reads the seed itself under the gate. -func seedScope(ctx context.Context, g graph.Store, seedID string) []string { - rows, err := graph.GetNodeKindsByIDsContext(ctx, g, []string{seedID}) - if err != nil || rows[seedID].RepoPrefix == "" { - return nil - } - return []string{rows[seedID].RepoPrefix} -} - -// nodeScope is the scope whose generation a record on n follows. -func nodeScope(n *graph.Node) []string { - if n == nil || n.RepoPrefix == "" { - return nil - } - return []string{n.RepoPrefix} -} - func entriesForTier(t tier) []Entry { if len(t.IDs) == 0 { return nil @@ -1442,8 +1015,8 @@ func safeUint64(v any) (uint64, bool) { return 0, false } -// InvalidateIndex advances the generation of every repository so every -// future Lookup recomputes against the new graph state. Call this whenever +// InvalidateIndex advances the global build counter so every future +// Lookup recomputes against the new graph state. Call this whenever // the graph mutates in a way that could change reach sets — at the // end of every IndexCtx / IncrementalReindexPaths / global-pass run. // @@ -1454,7 +1027,7 @@ func safeUint64(v any) (uint64, bool) { // invalidation is O(1) and only the seeds actually queried pay the // recompute cost. func InvalidateIndex() { - retireWhole() + atomic.AddUint64(&buildCounter, 1) } // readTier reconstructs an []Entry from the parallel arrays. Missing diff --git a/internal/reach/reach_retry_test.go b/internal/reach/reach_retry_test.go deleted file mode 100644 index 72c8a16e0..000000000 --- a/internal/reach/reach_retry_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package reach - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/zzet/gortex/internal/graph" -) - -// churningStore retires every reach generation while a traversal reads the -// seed's callers, so every lazy computation crosses a topology change. -type churningStore struct { - graph.Store - seed string - computes atomic.Int32 -} - -func (s *churningStore) GetInEdgesByNodeIDsContext(_ context.Context, ids []string, _ int) (map[string][]*graph.Edge, bool, error) { - if len(ids) == 1 && ids[0] == s.seed { - s.computes.Add(1) - InvalidateIndex() - } - return s.GetInEdgesByNodeIDs(ids), false, nil -} - -// A lookup crossed by a topology change retries once, then answers with what -// it read as a lower bound instead of spinning until its deadline. -func TestLookupRetriesOnceThenReportsALowerBound(t *testing.T) { - base := graph.New() - base.AddNode(&graph.Node{ID: "seed", Kind: graph.KindFunction, Name: "seed"}) - base.AddNode(&graph.Node{ID: "caller", Kind: graph.KindFunction, Name: "caller"}) - base.AddEdge(&graph.Edge{From: "caller", To: "seed", Kind: graph.EdgeCalls, Confidence: 1}) - store := &churningStore{Store: base, seed: "seed"} - - started := time.Now() - d1, _, _, hit, truncated := LookupContext(t.Context(), store, "seed") - elapsed := time.Since(started) - if got := store.computes.Load(); got != 2 { - t.Fatalf("lookup computed %d times in %s, want one retry", got, elapsed) - } - if !hit || !truncated { - t.Fatalf("hit=%v truncated=%v, want a lower-bound hit", hit, truncated) - } - if got := joinIDs(d1); got != "caller" { - t.Fatalf("d1 = %q, want the callers read as lower-bound evidence", got) - } - if elapsed > lookupTimeout/3 { - t.Fatalf("lookup took %s", elapsed) - } -} diff --git a/internal/reach/reach_sqlite_test.go b/internal/reach/reach_sqlite_test.go index 2ced82b4c..a788800e6 100644 --- a/internal/reach/reach_sqlite_test.go +++ b/internal/reach/reach_sqlite_test.go @@ -141,64 +141,3 @@ func TestLookupTraversalDoesNotStarveTopologyOrResolverLocks(t *testing.T) { <-done } - -// openSeedAndCaller opens a SQLite store holding one seed and its caller. -func openSeedAndCaller(t *testing.T) *store_sqlite.Store { - t.Helper() - store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "reach.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { store.Close() }) - store.AddBatch([]*graph.Node{ - {ID: "seed", Kind: graph.KindFunction, Name: "seed"}, - {ID: "caller", Kind: graph.KindFunction, Name: "caller"}, - }, []*graph.Edge{{From: "caller", To: "seed", Kind: graph.EdgeCalls, Confidence: 1}}) - return store -} - -// A SQLite read runs on its own snapshot, so a resolver or enrichment pass that -// holds ResolveMutex must not make an impact lookup wait for it. -func TestLookupDoesNotWaitForSQLiteResolveMutex(t *testing.T) { - store := openSeedAndCaller(t) - store.ResolveMutex().Lock() - defer store.ResolveMutex().Unlock() - started := time.Now() - d1, _, _, hit, truncated := LookupContext(t.Context(), store, "seed") - if elapsed := time.Since(started); elapsed > lookupTimeout/3 { - t.Fatalf("lookup waited %s behind ResolveMutex", elapsed) - } - if !hit || truncated || joinIDs(d1) != "caller" { - t.Fatalf("d1=%v hit=%v truncated=%v, want the caller as an exact hit", d1, hit, truncated) - } -} - -// A record write rewrites the seed's whole row, so on SQLite too it waits for a -// pass that holds ResolveMutex while it rewrites rows. -func TestReachRecordWritesWaitForSQLiteResolveMutex(t *testing.T) { - store := openSeedAndCaller(t) - mu := store.ResolveMutex() - - mu.Lock() - ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) - BuildIndexCtx(ctx, store) - cancel() - mu.Unlock() - if _, _, _, hit, _ := LookupCached(store, "seed"); hit { - t.Fatal("the eager build published a record while ResolveMutex was held") - } - - mu.Lock() - cleared := make(chan struct{}) - go func() { - ClearIndex(store) - close(cleared) - }() - select { - case <-cleared: - t.Error("ClearIndex rewrote rows while ResolveMutex was held") - case <-time.After(100 * time.Millisecond): - } - mu.Unlock() - <-cleared -} diff --git a/internal/reach/topology_bubble_test.go b/internal/reach/topology_bubble_test.go deleted file mode 100644 index e8ec3ec8d..000000000 --- a/internal/reach/topology_bubble_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package reach - -import ( - "context" - "testing" - "testing/synctest" -) - -// A real admission/release inside a fake-clock bubble must not leave a -// bubble-owned notification channel for the next ordinary graph operation. -func TestTopologyWakeDoesNotEscapeSynctestBubble(t *testing.T) { - for range 2 { - synctest.Test(t, func(t *testing.T) { - finish := beginTopologyWrite(nil, "") - finish(false, nil) - }) - finish := beginTopologyWrite(nil, "") - finish(false, nil) - } -} - -// The holder can outlive a cancelled reader. Once that reader has returned, -// its bubble must not remain the owner of the global notification channel. -func TestTopologyCancelledWaitDoesNotEscapeSynctestBubble(t *testing.T) { - finish := beginTopologyWrite(nil, "") - defer finish(false, nil) - synctest.Test(t, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - release, admitted := beginTopologyRead(ctx, nil) - if admitted { - release() - t.Error("reader entered while writer held the graph") - } - }() - synctest.Wait() - cancel() - <-done - }) - finish(false, nil) -} - -func TestTopologyWakeKeepsOtherWaitersWhenOneCancels(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - finish := beginTopologyWrite(nil, "") - defer finish(false, nil) - ctx, cancel := context.WithCancel(context.Background()) - cancelled := make(chan struct{}) - entered := make(chan struct{}) - go func() { - defer close(cancelled) - release, admitted := beginTopologyRead(ctx, nil) - if admitted { - release() - t.Error("cancelled reader entered while writer held the graph") - } - }() - go func() { - release, admitted := beginTopologyRead(context.Background(), nil) - if !admitted { - t.Error("live waiter was cancelled by another reader") - return - } - release() - close(entered) - }() - synctest.Wait() - cancel() - <-cancelled - synctest.Wait() - select { - case <-entered: - t.Fatal("live reader entered before writer release") - default: - } - finish(false, nil) - <-entered - }) - finish := beginTopologyWrite(nil, "") - finish(false, nil) -} diff --git a/internal/reach/topology_repo_test.go b/internal/reach/topology_repo_test.go deleted file mode 100644 index 28a94fe05..000000000 --- a/internal/reach/topology_repo_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package reach - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/zzet/gortex/internal/graph" - "github.com/zzet/gortex/internal/graph/store_sqlite" -) - -const ( - seedA = "a/a.go::A" - seedB = "b/b.go::B" -) - -// repoTopologyStore holds repositories a and b, one caller-callee pair in a and -// one function in b, plus the extra edges given. -func repoTopologyStore(t *testing.T, extra ...*graph.Edge) *store_sqlite.Store { - t.Helper() - store := openTopologyScopeStore(t) - store.AddBatch([]*graph.Node{ - {ID: seedA, Kind: graph.KindFunction, Name: "A", FilePath: "a/a.go", RepoPrefix: "a"}, - {ID: "a/a.go::CallsA", Kind: graph.KindFunction, Name: "CallsA", FilePath: "a/a.go", RepoPrefix: "a"}, - {ID: seedB, Kind: graph.KindFunction, Name: "B", FilePath: "b/b.go", RepoPrefix: "b"}, - {ID: "dep::x::X", Kind: graph.KindFunction, Name: "X"}, - }, append([]*graph.Edge{ - {From: "a/a.go::CallsA", To: seedA, Kind: graph.EdgeCalls, Confidence: 1}, - }, extra...)) - return store -} - -// awaitCoupling waits until done holds for the coupling of g. -func awaitCoupling(t *testing.T, g graph.Store, done func(*repoCoupling) bool) { - t.Helper() - deadline := time.Now().Add(topologyScopeWait) - for { - topology.mu.Lock() - ok := topology.coupling.store == g && done(&topology.coupling) - topology.mu.Unlock() - if ok { - return - } - if time.Now().After(deadline) { - t.Fatal("the repository coupling read never landed") - } - time.Sleep(time.Millisecond) - } -} - -// awaitRepoCoupling reads the coupling of g and waits until it is known. -func awaitRepoCoupling(t *testing.T, g graph.Store) { - t.Helper() - ensureRepoCoupling(g) - awaitCoupling(t, g, func(c *repoCoupling) bool { return c.ready }) -} - -// admitted reports whether begin returns within topologyScopeWait. -func admitted[F any](begin func() F) (F, bool) { - done := make(chan F, 1) - go func() { done <- begin() }() - select { - case finish := <-done: - return finish, true - case <-time.After(topologyScopeWait): - var none F - return none, false - } -} - -func lookupWithin(t *testing.T, g graph.Store, seed string, within time.Duration) bool { - t.Helper() - done := make(chan bool, 1) - go func() { - _, _, _, hit, truncated := LookupContext(context.Background(), g, seed) - done <- hit && !truncated - }() - select { - case exact := <-done: - if !exact { - t.Fatalf("lookup of %s was not an exact hit", seed) - } - return true - case <-time.After(within): - return false - } -} - -func TestRepoTopologyMutationsHoldOnlyTheirRepository(t *testing.T) { - store := repoTopologyStore(t) - awaitRepoCoupling(t, store) - finishA := BeginRepoTopologyMutation(store, "a") - - finishB, ok := admitted(func() func(bool, []string) { return BeginRepoTopologyMutation(store, "b") }) - if !ok { - finishA(false, nil) - t.Fatal("a mutation of b queued behind a mutation of a") - } - finishB(false, nil) - if !lookupWithin(t, store, seedB, topologyScopeWait) { - finishA(false, nil) - t.Fatal("a lookup in b waited for a mutation of a") - } - - blocked := make(chan bool, 1) - go func() { - _, _, _, hit, truncated := LookupContext(context.Background(), store, seedA) - blocked <- hit && !truncated - }() - whole := make(chan func(bool), 1) - go func() { whole <- BeginTopologyMutation(store) }() - select { - case <-blocked: - t.Fatal("a lookup in a escaped a mutation of a") - case <-whole: - t.Fatal("a whole-graph mutation entered beside a mutation of a") - case <-time.After(20 * time.Millisecond): - } - finishA(true, []string{"a/a.go"}) - finishWhole := <-whole - finishWhole(false) - select { - case exact := <-blocked: - if !exact { - t.Fatal("the lookup released by the mutation was not an exact hit") - } - case <-time.After(topologyScopeWait): - t.Fatal("a lookup in a stayed blocked after the mutation finished") - } -} - -func TestRepoTopologyMutationRetiresCoupledRecords(t *testing.T) { - bFiles := []string{"b/b.go"} - cases := []struct { - name string - extra []*graph.Edge - bind []*graph.Edge // bound inside the mutation of b - prefix string - frontier []string - retireA bool - }{ - {name: "independent", prefix: "b", frontier: bFiles}, - {name: "joined by an edge", prefix: "b", frontier: bFiles, retireA: true, extra: []*graph.Edge{ - {From: seedB, To: seedA, Kind: graph.EdgeCalls, Confidence: 1}, - }}, - {name: "joined through an unowned node", prefix: "b", frontier: bFiles, retireA: true, extra: []*graph.Edge{ - {From: seedB, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}, - {From: "dep::x::X", To: seedA, Kind: graph.EdgeValueFlow, Confidence: 1}, - }}, - {name: "joined by a reference the mutation bound", prefix: "b", frontier: bFiles, retireA: true, bind: []*graph.Edge{ - {From: seedB, To: seedA, Kind: graph.EdgeReferences, Confidence: 1}, - }}, - {name: "joined through an unowned node the mutation reached", prefix: "b", frontier: bFiles, retireA: true, - extra: []*graph.Edge{{From: "dep::x::X", To: seedA, Kind: graph.EdgeValueFlow, Confidence: 1}}, - bind: []*graph.Edge{{From: seedB, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}}, - }, - {name: "edges anywhere", prefix: "b", retireA: true}, - {name: "unknown repository", prefix: "", frontier: bFiles, retireA: true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - store := repoTopologyStore(t, tc.extra...) - awaitRepoCoupling(t, store) - BuildIndex(store) - finish := BeginRepoTopologyMutation(store, tc.prefix) - if tc.bind != nil { - store.AddBatch(nil, tc.bind) - } - finish(true, tc.frontier) - - if _, _, _, hit, _ := LookupCached(store, seedB); hit { - t.Fatal("the mutated repository kept its records") - } - if _, _, _, hit, _ := LookupCached(store, seedA); hit == tc.retireA { - t.Fatalf("record of a hit=%v after a mutation of %q, want %v", hit, tc.prefix, !tc.retireA) - } - }) - } -} - -// An unowned node that gains its first edge into a repository joins it with -// every repository that has an edge into the node. -func TestRepoCouplingLearnsAnUnownedNodeTurnedHub(t *testing.T) { - store := repoTopologyStore(t, &graph.Edge{From: seedA, To: "dep::x::X", Kind: graph.EdgeCalls, Confidence: 1}) - awaitRepoCoupling(t, store) - finish := BeginRepoTopologyMutation(store, "b") - store.AddBatch(nil, []*graph.Edge{{From: "dep::x::X", To: seedB, Kind: graph.EdgeValueFlow, Confidence: 1}}) - finish(true, []string{"b/b.go"}) - - BuildIndex(store) - finish = BeginRepoTopologyMutation(store, "a") - store.AddBatch( - []*graph.Node{{ID: "a/c.go::C", Kind: graph.KindFunction, Name: "C", FilePath: "a/c.go", RepoPrefix: "a"}}, - []*graph.Edge{{From: "a/c.go::C", To: seedA, Kind: graph.EdgeCalls, Confidence: 1}}, - ) - finish(true, []string{"a/c.go"}) - if _, _, _, hit, _ := LookupCached(store, seedB); hit { - t.Fatal("a new caller in a reaches b through an unowned node, but the records of b were kept") - } -} - -// A whole-graph mutation can join repositories anywhere, so the coupling is -// read again before a mutation of one repository narrows. -func TestWholeGraphMutationForgetsRepoCoupling(t *testing.T) { - store := repoTopologyStore(t) - awaitRepoCoupling(t, store) - - finish := BeginTopologyMutation(store) - store.AddBatch(nil, []*graph.Edge{{From: seedB, To: seedA, Kind: graph.EdgeCalls, Confidence: 1}}) - finish(true) - - awaitRepoCoupling(t, store) - BuildIndex(store) - BeginRepoTopologyMutation(store, "b")(true, []string{}) - if _, _, _, hit, _ := LookupCached(store, seedA); hit { - t.Fatal("a mutation of b kept the records of a after an edge joined them") - } -} - -// Without a coupling source every repository mutation holds the whole graph. -func TestRepoTopologyMutationWithoutCouplingHoldsTheWholeGraph(t *testing.T) { - g, ids := newCallChain(t, 3) - BuildIndex(g) - BeginRepoTopologyMutation(g, "b")(true, []string{}) - if _, _, _, hit, _ := LookupCached(g, ids[2]); hit { - t.Fatal("a repository mutation of a store without coupling kept unrelated records") - } -} - -// slowCouplingStore holds its coupling read until release and then reports no -// pairs, or err. -type slowCouplingStore struct { - *store_sqlite.Store - release chan struct{} - err error - reads atomic.Int32 -} - -func (s *slowCouplingStore) RepoCouplingPairs(ctx context.Context) ([][2]string, error) { - s.reads.Add(1) - select { - case <-s.release: - case <-ctx.Done(): - } - return nil, s.err -} - -// A mutation never waits for the coupling read; it holds the whole graph -// until the read lands, and what it bound meanwhile is added to the read's -// result, whose snapshot may predate it. -func TestRepoTopologyMutationDoesNotWaitForTheCouplingRead(t *testing.T) { - store := &slowCouplingStore{Store: repoTopologyStore(t), release: make(chan struct{})} - started := time.Now() - finish := BeginRepoTopologyMutation(store, "b") - store.AddBatch(nil, []*graph.Edge{{From: seedB, To: seedA, Kind: graph.EdgeReferences, Confidence: 1}}) - finish(true, []string{"b/b.go"}) - if waited := time.Since(started); waited > time.Second { - t.Fatalf("a mutation waited %s for the coupling read", waited) - } - - close(store.release) - awaitCoupling(t, store, func(c *repoCoupling) bool { return c.ready }) - BuildIndex(store) - BeginRepoTopologyMutation(store, "b")(true, []string{}) - if _, _, _, hit, _ := LookupCached(store, seedA); hit { - t.Fatal("the coupling lost an edge bound while it was being read") - } -} - -// A store whose coupling read fails, as one that runs out of budget does, -// keeps whole-graph mutations and is not read again. -func TestFailedCouplingReadIsNotRetried(t *testing.T) { - store := &slowCouplingStore{Store: repoTopologyStore(t), release: make(chan struct{}), err: context.DeadlineExceeded} - BeginRepoTopologyMutation(store, "b")(false, nil) - close(store.release) - awaitCoupling(t, store, func(c *repoCoupling) bool { return c.missed }) - - BuildIndex(store) - BeginRepoTopologyMutation(store, "b")(true, []string{"b/b.go"}) - if reads := store.reads.Load(); reads != 1 { - t.Fatalf("the coupling was read %d times, want once", reads) - } - if _, _, _, hit, _ := LookupCached(store, seedA); hit { - t.Fatal("a mutation of a store with no coupling kept the records of another repository") - } -} - -// A writer that queued before another mutation joined its repository to a -// neighbour must acquire the expanded component when it is admitted. -func TestWaitingRepoTopologyWriterRechecksNewCoupling(t *testing.T) { - store := repoTopologyStore(t) - awaitRepoCoupling(t, store) - finishA := BeginRepoTopologyMutation(store, "a") - finishB := BeginRepoTopologyMutation(store, "b") - queued := make(chan func(bool, []string), 1) - go func() { queued <- BeginRepoTopologyMutation(store, "a") }() - deadline := time.Now().Add(topologyScopeWait) - for { - topology.mu.Lock() - waiting := topology.repos["a"].writersWaiting > 0 - topology.mu.Unlock() - if waiting { - break - } - if time.Now().After(deadline) { - finishA(false, nil) - finishB(false, nil) - t.Fatal("the second writer never queued") - } - time.Sleep(time.Millisecond) - } - store.AddBatch(nil, []*graph.Edge{{From: seedA, To: seedB, Kind: graph.EdgeReferences}}) - finishA(true, []string{"a/a.go"}) - select { - case finish := <-queued: - finish(false, nil) - finishB(false, nil) - t.Fatal("the queued writer entered beside its newly coupled neighbour") - case <-time.After(20 * time.Millisecond): - } - finishB(false, nil) - (<-queued)(false, nil) -} diff --git a/internal/resolver/cross_repo_incremental.go b/internal/resolver/cross_repo_incremental.go index 96981097c..8727395c7 100644 --- a/internal/resolver/cross_repo_incremental.go +++ b/internal/resolver/cross_repo_incremental.go @@ -2,7 +2,6 @@ package resolver import ( "context" - "time" "go.uber.org/zap" @@ -59,28 +58,14 @@ func (cr *CrossRepoResolver) ResolveMutationFrontiers(resolutionFiles, edgeSourc // files' own outgoing edges, and its CrossRepoStats — which count exactly that // — would otherwise read as a complete pass. func (cr *CrossRepoResolver) ResolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles []string) (*CrossRepoStats, CrossRepoIncomingAdmission) { - return cr.resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles, nil) -} - -func (cr *CrossRepoResolver) resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) (*CrossRepoStats, CrossRepoIncomingAdmission) { stats := &CrossRepoStats{ByRepo: make(map[string]int)} var admission CrossRepoIncomingAdmission if cr == nil || cr.graph == nil || len(resolutionFiles) == 0 && len(edgeSourceFiles) == 0 && len(definitionFiles) == 0 { return stats, admission } - var started, acquired time.Time - if observe != nil { - started = time.Now() - } cr.mu.Lock() - if observe != nil { - acquired = time.Now() - } defer cr.mu.Unlock() - if observe != nil { - observe(started, acquired) - } if len(resolutionFiles) > 0 { frontier := collectIncrementalFileFrontier(cr.graph, resolutionFiles, nil) diff --git a/internal/resolver/resolve_mutex_observer.go b/internal/resolver/resolve_mutex_observer.go deleted file mode 100644 index 849cd7004..000000000 --- a/internal/resolver/resolve_mutex_observer.go +++ /dev/null @@ -1,24 +0,0 @@ -package resolver - -import "time" - -// ResolveFilesAndIncomingObserved reports this execution's actual mutex -// acquisition separately from module refresh and resolution. A nil observer -// retains the legacy path; an empty execution makes no observation. The -// callback runs under ResolveMutex and must be bounded and non-reentrant. -func (r *Resolver) ResolveFilesAndIncomingObserved(files []string, observe func(started, acquired time.Time)) *ResolveStats { - return r.resolveFilesAndIncoming(files, observe) -} - -// ResolveFilesAndIncoming runs the legacy unobserved execution. -func (r *Resolver) ResolveFilesAndIncoming(files []string) *ResolveStats { - return r.resolveFilesAndIncoming(files, nil) -} - -// ResolveMutationFrontiersObserved observes the scoped cross-repository -// execution's mutex acquisition. The callback has the same restrictions as -// ResolveFilesAndIncomingObserved; it is never installed on a shared resolver. -func (cr *CrossRepoResolver) ResolveMutationFrontiersObserved(resolutionFiles, edgeSourceFiles, definitionFiles []string, observe func(time.Time, time.Time)) *CrossRepoStats { - stats, _ := cr.resolveMutationFrontiersBounded(resolutionFiles, edgeSourceFiles, definitionFiles, observe) - return stats -} diff --git a/internal/resolver/resolve_mutex_observer_test.go b/internal/resolver/resolve_mutex_observer_test.go deleted file mode 100644 index ce907c5f2..000000000 --- a/internal/resolver/resolve_mutex_observer_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package resolver - -import ( - "reflect" - "sync" - "testing" - "time" - - "github.com/zzet/gortex/internal/graph" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" -) - -func TestMutationResolveMutexObserverEndsBeforeModuleRefresh(t *testing.T) { - g := graph.New() - r := New(g) - enteringLock, refreshing, permitRefresh, done := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) - var signal, unlock, allowRefresh sync.Once - core, _ := observer.New(zap.InfoLevel) - r.SetLogger(zap.New(zapcore.RegisterHooks(core, func(entry zapcore.Entry) error { - if entry.Message == "resolver: incremental phase starting" { - signal.Do(func() { close(enteringLock) }) - } - return nil - }))) - r.SetGoModuleLookup(func() map[string]string { close(refreshing); <-permitRefresh; return nil }) - observed := make(chan [2]time.Time, 1) - g.ResolveMutex().Lock() - release := func() { unlock.Do(g.ResolveMutex().Unlock) } - resume := func() { allowRefresh.Do(func() { close(permitRefresh) }) } - t.Cleanup(func() { - release() - resume() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Error("resolver did not drain") - } - }) - go func() { - defer close(done) - r.ResolveFilesAndIncomingObserved([]string{"missing.go"}, func(started, acquired time.Time) { observed <- [2]time.Time{started, acquired} }) - }() - select { - case <-enteringLock: - case <-time.After(2 * time.Second): - t.Fatal("resolve never reached mutex admission") - } - select { - case <-observed: - t.Fatal("held mutation mutex reported acquired before release") - default: - } - timer := time.NewTimer(20 * time.Millisecond) - <-timer.C - release() - select { - case <-refreshing: - case <-time.After(2 * time.Second): - t.Fatal("resolve did not reach module refresh") - } - select { - case interval := <-observed: - if interval[0].IsZero() || !interval[1].After(interval[0]) { - t.Fatalf("held mutex acquisition has no positive observed interval: %v", interval) - } - default: - t.Fatal("actual mutation acquisition was not observed before module refresh") - } - select { - case <-done: - t.Fatal("module refresh unexpectedly completed without its permit") - default: - } - resume() - <-done - select { - case <-observed: - t.Fatal("one execution reported multiple acquisition waits") - default: - } -} - -func TestMutationResolveMutexObserverLegacyNilAndEmptyCompatibility(t *testing.T) { - legacy := New(graph.New()).ResolveFilesAndIncoming([]string{"missing.go"}) - unobserved := New(graph.New()).ResolveFilesAndIncomingObserved([]string{"missing.go"}, nil) - if !reflect.DeepEqual(legacy, unobserved) { - t.Fatalf("nil observer changed resolution: legacy=%+v observed=%+v", legacy, unobserved) - } - calls := 0 - empty := New(graph.New()).ResolveFilesAndIncomingObserved(nil, func(time.Time, time.Time) { calls++ }) - if calls != 0 || !reflect.DeepEqual(empty, &ResolveStats{}) { - t.Fatal("empty execution fabricated an observed zero acquisition") - } -} - -func TestMutationResolveMutexObserverCrossRepoCompatibility(t *testing.T) { - files := []string{"missing.go"} - legacy := NewCrossRepo(graph.New()).ResolveMutationFrontiers(files, files, files) - unobserved := NewCrossRepo(graph.New()).ResolveMutationFrontiersObserved(files, files, files, nil) - if !reflect.DeepEqual(legacy, unobserved) { - t.Fatal("nil observer changed cross-repo resolution") - } - g := graph.New() - calls := 0 - NewCrossRepo(g).ResolveMutationFrontiersObserved(files, files, files, func(started, acquired time.Time) { - calls++ - if started.IsZero() || acquired.Before(started) { - t.Fatal("invalid cross-repo acquisition interval") - } - if g.ResolveMutex().TryLock() { - g.ResolveMutex().Unlock() - t.Fatal("cross-repo observer did not follow mutex acquisition") - } - }) - if calls != 1 { - t.Fatal("cross-repo execution did not report one actual acquisition") - } - NewCrossRepo(g).ResolveMutationFrontiersObserved(nil, nil, nil, func(time.Time, time.Time) { calls++ }) - if calls != 1 { - t.Fatal("empty cross-repo pass fabricated an acquisition") - } -} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 29daf9e57..7a34f7aba 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3073,13 +3073,13 @@ func collectIncrementalFileFrontierKeys( return frontier } -// resolveFilesAndIncoming runs the forward and reverse passes for a +// ResolveFilesAndIncoming runs the forward and reverse passes for a // batch of files under one lock, one build of the per-pass indexes, and // one run of the attribution passes. The affected-by re-resolution path // uses this: calling ResolveFileAndIncoming per file would rebuild the // four pass indexes and re-run the whole-graph attribution sweeps once // per file, turning a bounded fan-out into N whole-graph passes. -func (r *Resolver) resolveFilesAndIncoming(filePaths []string, observe func(started, acquired time.Time)) *ResolveStats { +func (r *Resolver) ResolveFilesAndIncoming(filePaths []string) *ResolveStats { stats := &ResolveStats{} if len(filePaths) == 0 { return stats @@ -3123,18 +3123,8 @@ func (r *Resolver) resolveFilesAndIncoming(filePaths []string, observe func(star zap.Duration("total", time.Since(started))) }() finish := r.startIncrementalPhase(logger, "wait_lock") - var mutexStarted, mutexAcquired time.Time - if observe != nil { - mutexStarted = time.Now() - } r.mu.Lock() - if observe != nil { - mutexAcquired = time.Now() - } defer r.mu.Unlock() - if observe != nil { - observe(mutexStarted, mutexAcquired) - } r.refreshGoModules() lockDuration = finish() // The edited files may have (re)stamped global usings — reconcile the From e1ee5b1eba5d11d372bd44fd6cc976bfcdcae23a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 2 Nov 2026 09:58:09 +0100 Subject: [PATCH 138/138] test(mcp): verify twenty-file renames in full and local ACK modes --- .../rename_twenty_file_ack_acceptance_test.go | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 internal/mcp/rename_twenty_file_ack_acceptance_test.go diff --git a/internal/mcp/rename_twenty_file_ack_acceptance_test.go b/internal/mcp/rename_twenty_file_ack_acceptance_test.go new file mode 100644 index 000000000..1c3c7df5b --- /dev/null +++ b/internal/mcp/rename_twenty_file_ack_acceptance_test.go @@ -0,0 +1,210 @@ +//go:build acceptance + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +// The micro fixture owns exactly twenty affected Go files and a real watcher. +// It is not the SQLite/reference-host/transport acceptance workload. +type renameTwentyFixture struct { + server *Server + root string + original map[string]string + invoke mcpserver.ToolHandlerFunc +} + +func newRenameTwentyFixture(tb testing.TB, mode config.EditAckMode) *renameTwentyFixture { + tb.Helper() + previous := pendingFileProjections + pendingFileProjections = newPendingProjections() + tb.Cleanup(func() { pendingFileProjections = previous }) + root := tb.TempDir() + files := map[string]string{"target.go": "package sample\nfunc Target() string { return \"unchanged\" }\n"} + for i := 1; i < 20; i++ { + files[fmt.Sprintf("caller%02d.go", i)] = fmt.Sprintf("package sample\nfunc Caller%02d() string { return Target() }\n", i) + } + require.NoError(tb, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/rename20\n\ngo 1.21\n"), 0600)) + for name, body := range files { + require.NoError(tb, os.WriteFile(filepath.Join(root, name), []byte(body), 0600)) + } + g := graph.New() + idx := indexer.New(g, testRegistry(), config.Default().Index, zap.NewNop()) + _, err := idx.Index(root) + require.NoError(tb, err) + configPath := filepath.Join(tb.TempDir(), "config.yaml") + // Omission exercises the production default full; local is explicit. + configBody := "" + if mode == config.EditAckLocal { + configBody = "edit:\n ack: local\n" + } + require.NoError(tb, os.WriteFile(configPath, []byte(configBody), 0600)) + manager, err := config.NewConfigManager(configPath) + require.NoError(tb, err) + s := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil, MultiRepoOptions{ConfigManager: manager}) + s.RunAnalysis() + require.Equal(tb, mode, s.sourceAckPolicy(tb.Context()).mode) + watcher, err := indexer.NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.NewNop()) + require.NoError(tb, err) + s.SetWatcher(watcher) + require.NoError(tb, watcher.Start([]string{root})) + tb.Cleanup(func() { require.NoError(tb, watcher.Stop()); idx.Close() }) + invoke := s.wrapToolHandler(func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return s.runAcknowledgedEdit(ctx, req, s.handleRenameSymbol) + }) + return &renameTwentyFixture{server: s, root: root, original: files, invoke: invoke} +} + +func (f *renameTwentyFixture) rename(tb testing.TB, before, after string) (time.Duration, []map[string]any) { + tb.Helper() + req := makeReq("rename_symbol", map[string]any{"id": "target.go::" + before, "new_name": after, "format": "json"}) + started := time.Now() + result, err := f.invoke(tb.Context(), req) + elapsed := time.Since(started) + if bench, ok := tb.(*testing.B); ok { + bench.StopTimer() + if result != nil { + bench.Logf("rename_ack_sample mode=%s before=%s after=%s elapsed_ns=%d response=%s", f.server.sourceAckPolicy(tb.Context()).mode, before, after, elapsed.Nanoseconds(), resultText(result)) + } + } + require.NoError(tb, err) + require.NotNil(tb, result) + require.False(tb, result.IsError, "%s", resultText(result)) + tb.Logf("semantic_rename mode=%s before=%s after=%s raw_response=%s", f.server.sourceAckPolicy(tb.Context()).mode, before, after, resultText(result)) + var body map[string]any + require.NoError(tb, json.Unmarshal([]byte(resultText(result)), &body)) + require.Equal(tb, "applied", body["status"]) + require.EqualValues(tb, 20, body["files_written"]) + rows, ok := body["files"].([]any) + require.True(tb, ok) + require.Len(tb, rows, 20) + files := make([]map[string]any, 0, len(rows)) + seen := make(map[string]bool, len(rows)) + for _, raw := range rows { + file, ok := raw.(map[string]any) + require.True(tb, ok) + name, ok := file["file"].(string) + require.True(tb, ok) + require.Contains(tb, f.original, name) + require.False(tb, seen[name]) + seen[name] = true + require.Equal(tb, "applied", file["status"]) + require.Equal(tb, "committed", file["disk_status"]) + require.Equal(tb, string(f.server.sourceAckPolicy(tb.Context()).mode), file["ack_mode"]) + if f.server.sourceAckPolicy(tb.Context()).mode == config.EditAckFull { + require.Equal(tb, "fresh", file["graph_status"], "full mode needs actual completion for every file") + } + current, err := os.ReadFile(filepath.Join(f.root, name)) + require.NoError(tb, err) + require.Equal(tb, strings.ReplaceAll(f.original[name], "Target", after), string(current)) + files = append(files, file) + } + return elapsed, files +} + +// Prepare the NEXT sample from an actually completed graph. This wait occurs +// after the measured ACK, is reported separately, and never grants local ACKs +// full-mode credit or represents sustained offered-load latency. +func (f *renameTwentyFixture) waitForPublication(tb testing.TB, files []map[string]any) time.Duration { + tb.Helper() + started := time.Now() + ctx, cancel := context.WithTimeout(tb.Context(), time.Minute) + defer cancel() + for _, file := range files { + id, ok := file["reindex_receipt"].(string) + if !ok { + // Completed full replies omit the queued-only receipt field. Require + // the actual completion facts already asserted on this same row. + require.Equal(tb, "full", file["ack_mode"]) + require.Equal(tb, "fresh", file["graph_status"]) + require.Equal(tb, true, file["reindexed"]) + continue + } + raw, ok := f.server.mutationReceipts.Load(id) + require.True(tb, ok) + receipt, ok := raw.(*mutationReceipt) + require.True(tb, ok) + select { + case <-receipt.done: + outcome := receipt.outcome(false) + require.NoError(tb, outcome.Err) + require.True(tb, outcome.Reindexed) + case <-ctx.Done(): + tb.Fatal(ctx.Err()) + } + } + // Native watcher echoes can carry a newer generation than the returned + // tool ticket. Prepare the next isolated sample from the CURRENT real + // relations tier for all affected paths, not merely a closed old ticket. + paths := make([]string, 0, len(f.original)) + for path := range f.original { + paths = append(paths, path) + } + slices.Sort(paths) + deadline, ok := ctx.Deadline() + require.True(tb, ok) + fresh := f.server.awaitRelationFreshness(ctx, nil, paths, deadline) + require.NoError(tb, ctx.Err()) + require.Empty(tb, fresh.state, "next-sample preparation must not permit provisional or failed relations: %v", fresh.files) + return time.Since(started) +} + +func TestRenameSymbolTwentyFileMicroFixture(t *testing.T) { + for _, mode := range []config.EditAckMode{config.EditAckFull, config.EditAckLocal} { + t.Run(string(mode), func(t *testing.T) { + fixture := newRenameTwentyFixture(t, mode) + _, files := fixture.rename(t, "Target", "Renamed") + fixture.waitForPublication(t, files) + _, files = fixture.rename(t, "Renamed", "Target") + fixture.waitForPublication(t, files) + }) + } +} + +func BenchmarkRenameSymbolAck20Files(b *testing.B) { + for _, mode := range []config.EditAckMode{config.EditAckFull, config.EditAckLocal} { + b.Run(string(mode), func(b *testing.B) { + fixture := newRenameTwentyFixture(b, mode) + samples := make([]time.Duration, 0, b.N) + var between time.Duration + before, after := "Target", "Renamed" + b.ResetTimer() + for i := 0; i < b.N; i++ { + elapsed, files := fixture.rename(b, before, after) + b.StopTimer() + samples = append(samples, elapsed) + between += fixture.waitForPublication(b, files) + before, after = after, before + b.StartTimer() + } + b.StopTimer() + slices.Sort(samples) + require.NotEmpty(b, samples) + p95 := (95*len(samples)+99)/100 - 1 + b.ReportMetric(float64(samples[p95])/float64(time.Millisecond), "ack-p95-ms") + b.ReportMetric(float64(samples[len(samples)-1])/float64(time.Millisecond), "ack-max-ms") + b.ReportMetric(float64(between)/float64(time.Millisecond)/float64(len(samples)), "between-sample-publication-ms") + b.ReportMetric(float64(len(samples)), "actual-acks") + b.ReportMetric(20, "affected-files") + }) + } +}