From ddefcb768721394d1f3205614dccbe9d36eb5d3f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:15:57 +0100 Subject: [PATCH 01/34] fix(startup): declare configured repos before listening --- cmd/gortex/daemon.go | 2 +- cmd/gortex/daemon_repo_loading.go | 25 ++++--- cmd/gortex/daemon_startup_loading_test.go | 87 +++++++++++++++++++++++ internal/indexer/repo_load_state.go | 51 +++++++++++-- internal/indexer/repo_load_state_test.go | 46 ++++++++++++ 5 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 cmd/gortex/daemon_startup_loading_test.go diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go index b5b325455..82d04f17e 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -605,7 +605,7 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { state.lifecycle.EnableDeferredSeedRetirements() } - if err := srv.Listen(); err != nil { + if err := listenConfiguredDaemon(srv, state); err != nil { startupReporter.Fail(err) return err } diff --git a/cmd/gortex/daemon_repo_loading.go b/cmd/gortex/daemon_repo_loading.go index 0159b3d41..42c9f1f21 100644 --- a/cmd/gortex/daemon_repo_loading.go +++ b/cmd/gortex/daemon_repo_loading.go @@ -5,20 +5,27 @@ import ( "github.com/zzet/gortex/internal/indexer" ) -// announceConfiguredRepos makes every configured repository read as loading. -// -// The warmup goroutine calls it first, ahead of checkout seeding rather than -// when warmup reaches its job loop: seeding takes seconds on a large worktree -// farm, and for that whole window the admission gate otherwise told sessions -// their configured repository was untracked. It is not called before Listen: -// it stats every configured root, and a hung mount must not keep the socket -// from coming up. +// listenConfiguredDaemon publishes lexical configured roots before opening +// either listener. It performs no filesystem validation of those roots; warmup +// does that later so a hung mount cannot delay serving other configured repos. +func listenConfiguredDaemon(srv *daemon.Server, state *daemonState) error { + if state != nil && state.multiIndexer != nil && state.configManager != nil { + if gc := state.configManager.Global(); gc != nil { + state.multiIndexer.DeclareRepos(gc.ReposSnapshot()) + } + } + return srv.Listen() +} + +// announceConfiguredRepos fills the declarations' filesystem identity before +// checkout seeding. Serving has already started, so filesystem work stays here +// rather than in the lexical declaration that precedes Listen. func announceConfiguredRepos(state *daemonState) { if state == nil || state.multiIndexer == nil || state.configManager == nil { return } if gc := state.configManager.Global(); gc != nil { - state.multiIndexer.ExpectRepos(gc.Repos) + state.multiIndexer.ExpectRepos(gc.ReposSnapshot()) } } diff --git a/cmd/gortex/daemon_startup_loading_test.go b/cmd/gortex/daemon_startup_loading_test.go new file mode 100644 index 000000000..24206acaf --- /dev/null +++ b/cmd/gortex/daemon_startup_loading_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" +) + +// Exercise the real pre-listen helper and public MCP socket before ANY warmup +// announcement, seeding or repository indexing. Starting registration by hand +// here would conceal the startup window this test is intended to cover. +func TestConfiguredLoadingPublishedBeforeServing(t *testing.T) { + registered := t.TempDir() + d, mi := trackedPathMCPSetup(t, registered) + root := filepath.Join(t.TempDir(), "configured") + admin := filepath.Join(root, ".git", "worktrees", "linked") + linked := filepath.Join(t.TempDir(), "linked") + require.NoError(t, os.MkdirAll(admin, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "pkg"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(linked, "pkg"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(admin, "commondir"), []byte("../..\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+admin+"\n"), 0o600)) + c := probeController(t, fmt.Sprintf("repos:\n - path: %q\n name: startup\n", root)) + state := &daemonState{multiIndexer: mi, configManager: c.configManager} + + file, err := os.CreateTemp(os.TempDir(), "gx-startup-*.sock") + require.NoError(t, err) + socket := file.Name() + require.NoError(t, file.Close()) + require.NoError(t, os.Remove(socket)) + t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(t.TempDir(), "daemon.pid")) + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(t.TempDir(), "daemon.state.json")) + srv := daemon.New(socket, "test", zap.NewNop()) + srv.MCPDispatcher = d + require.NoError(t, listenConfiguredDaemon(srv, state)) + served := make(chan error, 1) + go func() { served <- srv.Serve() }() + t.Cleanup(func() { + require.NoError(t, srv.Shutdown()) + select { + case err := <-served: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Error("owned startup test server did not stop") + } + }) + + for name, cwd := range map[string]string{ + "primary": root, "subdirectory": filepath.Join(root, "pkg"), + "linked": linked, "linked subdirectory": filepath.Join(linked, "pkg"), + } { + t.Run(name, func(t *testing.T) { + client, err := daemon.DialTo(socket, daemon.Handshake{Mode: daemon.ModeMCP, CWD: cwd}) + require.NoError(t, err) + defer client.Close() + require.NoError(t, client.Conn.SetDeadline(time.Now().Add(5*time.Second))) + require.NoError(t, client.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"startup-probe","version":"1"}}}`))) + _, err = client.ReadMCPFrame() + require.NoError(t, err) + require.NoError(t, client.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`))) + frame, err := client.ReadMCPFrame() + require.NoError(t, err) + var reply map[string]any + require.NoError(t, json.Unmarshal(frame, &reply)) + data := errorData(t, reply) + assert.Equal(t, "repo_loading", data["error_code"]) + assert.Equal(t, true, data["retryable"]) + assert.Contains(t, data, "phase") + assert.Contains(t, data, "percent") + assert.NotContains(t, string(frame), "gortex track") + assert.NotContains(t, string(frame), "repos reconcile") + }) + } + assert.Empty(t, mi.GetMetadata("startup"), "pending declarations must never register or admit graph access") + untracked := &daemon.Session{ID: "unconfigured", CWD: t.TempDir()} + assert.Equal(t, "repo_not_tracked", errorCode(dispatchJSON(t, d, untracked, + `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`))) +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index 080b705d4..ecc9ef356 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -73,10 +73,14 @@ type RepoLoad struct { // never touches the filesystem. key string canonicalKey string - // commonDir is Root's shared git directory, read once when the record is - // created, so a linked worktree of a loading primary is recognised without - // spawning git on the admission path. + // commonDir is the shared git directory resolved off the serving path. + // A declaration uses only root/.git as a loading-only candidate until + // warmup resolves identity; a request must independently resolve its own + // linked-worktree marker before this can produce a pending refusal. commonDir string + // identityPending distinguishes a lexical boot declaration from the + // filesystem identity warmup fills in. It grants no graph admission. + identityPending bool } // Label names the repository in user-facing text: its prefix, or its root @@ -183,14 +187,27 @@ func absRepoRoot(path string) string { return pathkey.NormalizeVolume(abs) } +// DeclareRepos publishes configured roots before the daemon listens without +// touching those roots, resolving symlinks or reading git metadata. A hung +// configured mount must not delay the listener or hide the other declarations. +// ExpectRepos fills in filesystem identity and missing-path state during warmup. +func (mi *MultiIndexer) DeclareRepos(entries []config.RepoEntry) { + mi.expectRepos(entries, false) +} + // ExpectRepos records the configured repositories a start is about to load. // -// Called once before checkout seeding, so a configured repository reads as -// loading from the moment the socket serves, and again when warmup takes its -// own snapshot of the config. A repository already recorded keeps its record; +// Called off the serving path before checkout seeding and again when warmup +// takes its own snapshot of the config. DeclareRepos publishes lexical roots +// before the socket serves; this enriches those declarations without resetting +// progress or a registration that raced with warmup. A repository already recorded keeps its record; // one that is no longer configured and not registered is dropped, so a stale // entry can never read as loading forever. func (mi *MultiIndexer) ExpectRepos(entries []config.RepoEntry) { + mi.expectRepos(entries, true) +} + +func (mi *MultiIndexer) expectRepos(entries []config.RepoEntry, inspect bool) { if mi == nil { return } @@ -200,6 +217,7 @@ func (mi *MultiIndexer) ExpectRepos(entries []config.RepoEntry) { wanted := make(map[int]bool, len(entries)) addedKeys := make(map[string]bool) var added []RepoLoad + identityChanged := false for _, entry := range entries { root := absRepoRoot(entry.Path) if root == "" || addedKeys[pathkey.IdentityKey(root)] { @@ -207,6 +225,25 @@ func (mi *MultiIndexer) ExpectRepos(entries []config.RepoEntry) { } if i, ok := current.find(root); ok { wanted[i] = true + if inspect && records[i].identityPending { + rec := &records[i] + resolved := newRepoLoad(root, rec.Prefix, rec.State, rec.Since) + rec.canonicalKey, rec.commonDir = resolved.canonicalKey, resolved.commonDir + rec.identityPending = false + if rec.State == RepoLoadQueued { + if info, err := os.Stat(root); err != nil || !info.IsDir() { + rec.State, rec.Reason, rec.Since = RepoLoadMissing, "the configured path does not exist", now + } + } + identityChanged = true + } + continue + } + if !inspect { + rec := RepoLoad{Root: root, Prefix: config.ResolvePrefix(entry), State: RepoLoadQueued, + Since: now, key: pathkey.IdentityKey(root), commonDir: filepath.Join(root, ".git"), identityPending: true} + added = append(added, rec) + addedKeys[rec.key] = true continue } state := RepoLoadQueued @@ -226,7 +263,7 @@ func (mi *MultiIndexer) ExpectRepos(entries []config.RepoEntry) { } } next = append(next, added...) - return next, len(added) > 0 || len(next) != len(records) + return next, identityChanged || len(added) > 0 || len(next) != len(records) }) } diff --git a/internal/indexer/repo_load_state_test.go b/internal/indexer/repo_load_state_test.go index cdcc8ee84..93a68f937 100644 --- a/internal/indexer/repo_load_state_test.go +++ b/internal/indexer/repo_load_state_test.go @@ -250,3 +250,49 @@ func TestPrimaryRegisteredStartsTheCoordinatorsThatWaitedForIt(t *testing.T) { require.Eventually(t, func() bool { return f.lc.hasCoordinator(checkout.CheckoutID) }, 5*time.Second, 20*time.Millisecond, "the primary's registration must start the coordinator that waited for it") } + +func TestDeclareRepos_DefersFilesystemIdentityUntilWarmup(t *testing.T) { + mi := newRepoLoadTestMulti(t) + missing := filepath.Join(t.TempDir(), "not-mounted") + entries := []config.RepoEntry{{Path: missing, Name: "pending"}, {Path: missing}} + mi.DeclareRepos(entries) + require.Len(t, mi.RepoLoads(), 1) + rec, ok := mi.RepoLoadFor(missing) + require.True(t, ok) + assert.Equal(t, RepoLoadQueued, rec.State, "declaration must not stat a configured root before Listen") + assert.True(t, rec.identityPending) + assert.Empty(t, rec.Reason) + rec, pending := mi.PendingRepoForPath(filepath.Join(missing, "pkg")) + require.True(t, pending) + assert.Equal(t, "pending", rec.Prefix) + + mi.ExpectRepos(entries) + rec, ok = mi.RepoLoadFor(missing) + require.True(t, ok) + assert.Equal(t, RepoLoadMissing, rec.State, "warmup still diagnoses missing paths") + assert.False(t, rec.identityPending) +} + +func TestDeclareRepos_WarmupEnrichmentPreservesRunningState(t *testing.T) { + mi := newRepoLoadTestMulti(t) + root, worktree := linkedWorktreeLayout(t) + entries := []config.RepoEntry{{Path: root}} + mi.DeclareRepos(entries) + rec, pending := mi.PendingRepoForPath(filepath.Join(worktree, "pkg")) + require.True(t, pending, "a primary declaration must cover independently resolved linked-worktree markers") + assert.Equal(t, root, rec.Root) + + mi.StartRepoLoad(root) + before, _ := mi.RepoLoadFor(root) + mi.ExpectRepos(entries) + after, ok := mi.RepoLoadFor(root) + require.True(t, ok) + assert.Equal(t, RepoLoadRunning, after.State) + assert.Equal(t, before.Since, after.Since) + assert.False(t, after.identityPending) + _, pending = mi.PendingRepoForPath(worktree) + assert.True(t, pending) + + mi.DeclareRepos(nil) + assert.Empty(t, mi.RepoLoads(), "removed declarations must not remain loading forever") +} From 99e6caa001a4a8ff1895aead1a93167d858522bb Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:20:07 +0100 Subject: [PATCH 02/34] test(startup): assert loading instructions on first initialize --- cmd/gortex/daemon_startup_loading_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/gortex/daemon_startup_loading_test.go b/cmd/gortex/daemon_startup_loading_test.go index 24206acaf..f279ac796 100644 --- a/cmd/gortex/daemon_startup_loading_test.go +++ b/cmd/gortex/daemon_startup_loading_test.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/zzet/gortex/internal/daemon" + gortexmcp "github.com/zzet/gortex/internal/mcp" ) // Exercise the real pre-listen helper and public MCP socket before ANY warmup @@ -64,8 +65,16 @@ func TestConfiguredLoadingPublishedBeforeServing(t *testing.T) { defer client.Close() require.NoError(t, client.Conn.SetDeadline(time.Now().Add(5*time.Second))) require.NoError(t, client.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"startup-probe","version":"1"}}}`))) - _, err = client.ReadMCPFrame() + initialized, err := client.ReadMCPFrame() require.NoError(t, err) + var initReply struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(initialized, &initReply)) + assert.Contains(t, initReply.Result.Instructions, gortexmcp.ServerInstructionsLoading("startup", "starting", 0), + "the first real initialize must carry the loading variant before warmup") require.NoError(t, client.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`))) frame, err := client.ReadMCPFrame() require.NoError(t, err) From 70b0b3d960b79f68e8a159c12d501a729d0396ec Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:38:58 +0100 Subject: [PATCH 03/34] test(startup): cover file-save events and real cold-track failure --- cmd/gortex/daemon_mcp_repo_loading_test.go | 20 +++++++++++++ internal/indexer/repo_load_state_test.go | 34 ++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/cmd/gortex/daemon_mcp_repo_loading_test.go b/cmd/gortex/daemon_mcp_repo_loading_test.go index 18934ba35..5ed6c78ad 100644 --- a/cmd/gortex/daemon_mcp_repo_loading_test.go +++ b/cmd/gortex/daemon_mcp_repo_loading_test.go @@ -273,3 +273,23 @@ func TestDispatcher_LoadingRepo_ToolsWorkInPlaceAfterRegistration(t *testing.T) assert.Empty(t, errorCode(parsed), "the same session must be served after registration: %v", parsed) assert.Contains(t, toolResultText(t, parsed), "LoadProbe", "the session's scope must bind to the repository it is in") } + +func TestDispatcher_ColdTrackFailureBecomesUnavailablePromptly(t *testing.T) { + d, mi, registered, loading := loadingDispatcherSetup(t) + mi.StartRepoLoad(loading) + // Use the real cold-track path and a genuine ownership failure, not a + // synthetic FinishRepoLoad error. The existing owner must stay registered. + _, err := mi.TrackRepoCtx(context.Background(), config.RepoEntry{Path: loading, Name: filepath.Base(registered)}) + var collision *indexer.PrefixCollisionError + require.ErrorAs(t, err, &collision) + failed := time.Now() + mi.FinishRepoLoad(loading, err) + sess := &daemon.Session{ID: "cold_failure", CWD: loading} + data := errorData(t, dispatchJSON(t, d, sess, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"LoadProbe"}}}`)) + assert.Equal(t, "repo_unavailable", data["error_code"]) + assert.Equal(t, false, data["retryable"]) + assert.Contains(t, data["reason"], "already used") + assert.Less(t, time.Since(failed), time.Second, "a completed cold-track failure must not remain repo_loading") + assert.NotNil(t, mi.GetMetadata(filepath.Base(registered)), "failure must not displace the prefix owner") +} diff --git a/internal/indexer/repo_load_state_test.go b/internal/indexer/repo_load_state_test.go index 93a68f937..23b4a3dc2 100644 --- a/internal/indexer/repo_load_state_test.go +++ b/internal/indexer/repo_load_state_test.go @@ -296,3 +296,37 @@ func TestDeclareRepos_WarmupEnrichmentPreservesRunningState(t *testing.T) { mi.DeclareRepos(nil) assert.Empty(t, mi.RepoLoads(), "removed declarations must not remain loading forever") } + +// The daemon invalidates cached scopes and publishes workspace_readiness from +// OnRepoRegistered. A real file-save mutation must not trigger that boot event. +func TestRepoRegistration_FileSaveDoesNotAnnounceAgain(t *testing.T) { + mi := newRepoLoadTestMulti(t) + root := filepath.Join(t.TempDir(), "saved") + writeRepoLoadSource(t, root) + mi.ExpectRepos([]config.RepoEntry{{Path: root}}) + announced := make(chan RepoLoad, 4) + mi.OnRepoRegistered(func(rec RepoLoad) { announced <- rec }) + res, err := mi.TrackRepoCtx(context.Background(), config.RepoEntry{Path: root}) + require.NoError(t, err) + select { + case <-announced: + case <-time.After(time.Second): + t.Fatal("initial cold registration was not announced") + } + before, ok := mi.RepoLoadFor(root) + require.True(t, ok) + require.Equal(t, RepoLoadReady, before.State) + idx := mi.GetIndexer(res.RepoPrefix) + require.NotNil(t, idx) + require.NoError(t, os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc SavedAfterRegistration() {}\n"), 0o644)) + require.NoError(t, idx.IndexFile("main.go")) + after, ok := mi.RepoLoadFor(root) + require.True(t, ok) + assert.Equal(t, before.Since, after.Since, "a save must not create another registration epoch") + assert.Equal(t, before.State, after.State) + select { + case rec := <-announced: + t.Fatalf("file save emitted a boot readiness/scope-invalidation event: %+v", rec) + case <-time.After(100 * time.Millisecond): + } +} From 33d8735486ed21b078afceae191d969d6c8234b2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:40:19 +0100 Subject: [PATCH 04/34] test(startup): register CLI fixtures through production tracking --- cmd/gortex/daemon_controller_worktree_test.go | 8 ++++++-- cmd/gortex/daemon_status_repo_counts_test.go | 4 ++-- cmd/gortex/repo_inventory_missing_test.go | 9 ++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/gortex/daemon_controller_worktree_test.go b/cmd/gortex/daemon_controller_worktree_test.go index dfeaeb105..a39318f61 100644 --- a/cmd/gortex/daemon_controller_worktree_test.go +++ b/cmd/gortex/daemon_controller_worktree_test.go @@ -75,8 +75,12 @@ func buildWorktreeController(t *testing.T) (*realController, *indexer.MultiIndex reg := parser.NewRegistry() languages.RegisterAll(reg) mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // Exercise the same registration path as a cold production startup. This + // fixture asserts worktree identity/reload effects, not batch resolution. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } // An in-memory graph has no checkout catalog, so the lifecycle runs in // its degraded shape here: no identities, and the same index / watcher / diff --git a/cmd/gortex/daemon_status_repo_counts_test.go b/cmd/gortex/daemon_status_repo_counts_test.go index 9cb431a04..e558d44c8 100644 --- a/cmd/gortex/daemon_status_repo_counts_test.go +++ b/cmd/gortex/daemon_status_repo_counts_test.go @@ -54,7 +54,7 @@ func TestStatus_TrackedRepoRowFollowsTheLiveGraph(t *testing.T) { reg := parser.NewRegistry() languages.RegisterAll(reg) mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() + _, err = mi.TrackRepoCtx(context.Background(), config.RepoEntry{Path: dir}) require.NoError(t, err) metas := mi.AllMetadata() @@ -130,7 +130,7 @@ func TestStatus_TwoReposEachReportTheirOwnBucket(t *testing.T) { // A is indexed while it is the only configured repo. g := graph.New() mi1 := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi1.IndexAll() + _, err = mi1.TrackRepoCtx(context.Background(), config.RepoEntry{Path: dirA}) require.NoError(t, err) var priorA map[string]int64 for _, m := range mi1.AllMetadata() { diff --git a/cmd/gortex/repo_inventory_missing_test.go b/cmd/gortex/repo_inventory_missing_test.go index 53ff7d2cc..3fe0053c0 100644 --- a/cmd/gortex/repo_inventory_missing_test.go +++ b/cmd/gortex/repo_inventory_missing_test.go @@ -117,7 +117,14 @@ func newInventoryController(t *testing.T, repos []config.RepoEntry) *realControl reg := parser.NewRegistry() languages.RegisterAll(reg) mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, _ = mi.IndexAll() + mi.ExpectRepos(repos) + for _, entry := range repos { + mi.StartRepoLoad(entry.Path) + _, trackErr := mi.TrackRepoCtx(context.Background(), entry) + // Missing entries deliberately remain configured but unavailable; + // inventory/status assertions below must still report those entries. + mi.FinishRepoLoad(entry.Path, trackErr) + } return &realController{graph: g, multiIndexer: mi, configManager: cm, logger: zap.NewNop()} } From 28d7ed11ef40461264f2186d380c1cfbae3efb60 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:42:01 +0100 Subject: [PATCH 05/34] test(startup): register MCP path fixtures through production tracking --- internal/mcp/ensure_fresh_self_heal_test.go | 8 ++++++-- internal/mcp/freshness_rider_test.go | 9 +++++++-- internal/mcp/path_indexability_test.go | 8 ++++++-- internal/mcp/tools_fileops_singlerepo_test.go | 16 ++++++++++++---- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/internal/mcp/ensure_fresh_self_heal_test.go b/internal/mcp/ensure_fresh_self_heal_test.go index 193cf393a..827731fd2 100644 --- a/internal/mcp/ensure_fresh_self_heal_test.go +++ b/internal/mcp/ensure_fresh_self_heal_test.go @@ -44,8 +44,12 @@ func TestEnsureFresh_MultiRepoSelfHealsStaleFile(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // These fixtures test path ownership/freshness, not cross-repo batch + // resolution. Register their explicit roots through the production path. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) diff --git a/internal/mcp/freshness_rider_test.go b/internal/mcp/freshness_rider_test.go index dca6a327f..213148f8c 100644 --- a/internal/mcp/freshness_rider_test.go +++ b/internal/mcp/freshness_rider_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "encoding/json" "os" "path/filepath" @@ -106,8 +107,12 @@ func TestMultiRepoRiderAndMissingFileFlag(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // These fixtures test path ownership/freshness, not cross-repo batch + // resolution. Register their explicit roots through the production path. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/path_indexability_test.go b/internal/mcp/path_indexability_test.go index c77a59582..fd517fdb0 100644 --- a/internal/mcp/path_indexability_test.go +++ b/internal/mcp/path_indexability_test.go @@ -151,8 +151,12 @@ func newPrefixFixture(t *testing.T) (*Server, string, string) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // These fixtures test path ownership/freshness, not cross-repo batch + // resolution. Register their explicit roots through the production path. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } return &Server{multiIndexer: mi}, repoA, repoB } diff --git a/internal/mcp/tools_fileops_singlerepo_test.go b/internal/mcp/tools_fileops_singlerepo_test.go index 5125bccac..2f80f3da6 100644 --- a/internal/mcp/tools_fileops_singlerepo_test.go +++ b/internal/mcp/tools_fileops_singlerepo_test.go @@ -39,8 +39,12 @@ func newSingleRepoServer(t *testing.T) (*Server, *graph.Graph, string) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // These fixtures test path ownership/freshness, not cross-repo batch + // resolution. Register their explicit roots through the production path. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, @@ -117,8 +121,12 @@ func TestResolveFilePath_MultiRepoBareRelativeStillAmbiguous(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + // These fixtures test path ownership/freshness, not cross-repo batch + // resolution. Register their explicit roots through the production path. + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, From 14aa44fde60175b84bb728a515bc6f3283719608 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:47:50 +0100 Subject: [PATCH 06/34] test(startup): register MCP scope fixtures through tracking --- internal/mcp/diff_repo_scope_test.go | 6 ++++-- internal/mcp/freshness_rider_extractor_lang_test.go | 7 +++++-- internal/mcp/graph_stats_per_repo_test.go | 6 ++++-- internal/mcp/index_health_empty_repo_test.go | 7 +++++-- internal/mcp/instructions_test.go | 6 ++++-- internal/mcp/search_corpus_scope_test.go | 6 ++++-- internal/mcp/tools_review_rulepack_test.go | 6 ++++-- internal/mcp/tools_search_text_pathsep_test.go | 6 ++++-- 8 files changed, 34 insertions(+), 16 deletions(-) diff --git a/internal/mcp/diff_repo_scope_test.go b/internal/mcp/diff_repo_scope_test.go index 213da63c0..169dddc59 100644 --- a/internal/mcp/diff_repo_scope_test.go +++ b/internal/mcp/diff_repo_scope_test.go @@ -42,8 +42,10 @@ func TestDiffRepoScope(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/freshness_rider_extractor_lang_test.go b/internal/mcp/freshness_rider_extractor_lang_test.go index d5bd0bb66..719a0f0a7 100644 --- a/internal/mcp/freshness_rider_extractor_lang_test.go +++ b/internal/mcp/freshness_rider_extractor_lang_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "encoding/json" "os" "path/filepath" @@ -64,8 +65,10 @@ func TestFreshnessRider_ExtractorStalenessIsScopedToTheFilesLanguage(t *testing. g := graph.New() store := &riderIndexStateStore{Store: g} mi := indexer.NewMultiIndexer(store, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } // Use the actual policy snapshot persisted by this index, so unrelated // extractor or global policy bumps cannot accidentally stale the Go file. diff --git a/internal/mcp/graph_stats_per_repo_test.go b/internal/mcp/graph_stats_per_repo_test.go index 6401705ef..b2a3cd4f8 100644 --- a/internal/mcp/graph_stats_per_repo_test.go +++ b/internal/mcp/graph_stats_per_repo_test.go @@ -41,8 +41,10 @@ func setupMultiRepoStatsServer(t *testing.T) *Server { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) diff --git a/internal/mcp/index_health_empty_repo_test.go b/internal/mcp/index_health_empty_repo_test.go index 323b5355b..3664677a4 100644 --- a/internal/mcp/index_health_empty_repo_test.go +++ b/internal/mcp/index_health_empty_repo_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "os" "path/filepath" "testing" @@ -43,8 +44,10 @@ func healthServerWithIndexedRepos(t *testing.T, named map[string]string) *Server reg := parser.NewRegistry() reg.Register(languages.NewGoExtractor()) mi := indexer.NewMultiIndexer(graph.New(), reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } srv.configManager = cm srv.multiIndexer = mi diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go index 825054787..480449c77 100644 --- a/internal/mcp/instructions_test.go +++ b/internal/mcp/instructions_test.go @@ -179,8 +179,10 @@ func TestStateAwareInstructionsVariants(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/search_corpus_scope_test.go b/internal/mcp/search_corpus_scope_test.go index 5e51acdd0..356e01ae4 100644 --- a/internal/mcp/search_corpus_scope_test.go +++ b/internal/mcp/search_corpus_scope_test.go @@ -58,8 +58,10 @@ func newWorkspaceRootBoundServer(t *testing.T) (s *Server, root string) { reg := parser.NewRegistry() reg.Register(languages.NewGoExtractor()) mi := indexer.NewMultiIndexer(store, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } // A content node owned by z, with its body in the content index. store.AddNode(&graph.Node{ diff --git a/internal/mcp/tools_review_rulepack_test.go b/internal/mcp/tools_review_rulepack_test.go index 2c7668063..c0e4fdfc0 100644 --- a/internal/mcp/tools_review_rulepack_test.go +++ b/internal/mcp/tools_review_rulepack_test.go @@ -51,8 +51,10 @@ func prefixedServerOver(t *testing.T, dir, name string) (*Server, string) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/tools_search_text_pathsep_test.go b/internal/mcp/tools_search_text_pathsep_test.go index abf0a31cb..c2d0770ae 100644 --- a/internal/mcp/tools_search_text_pathsep_test.go +++ b/internal/mcp/tools_search_text_pathsep_test.go @@ -62,8 +62,10 @@ func nestedRepoServer(t *testing.T, entries []config.RepoEntry) (*Server, *graph reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + for _, entry := range cm.Global().ReposSnapshot() { + _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + require.NoError(t, err) + } singleton := indexer.New(g, reg, config.IndexConfig{}, zap.NewNop()) srv := NewServer(query.NewEngine(g), g, singleton, nil, zap.NewNop(), nil, MultiRepoOptions{ From 837fcc257d223dede8d21a49ac68df7fe9f9ba34 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:52:47 +0100 Subject: [PATCH 07/34] test(startup): pass complete entries to fixture registration --- internal/mcp/diff_repo_scope_test.go | 2 +- internal/mcp/freshness_rider_extractor_lang_test.go | 2 +- internal/mcp/graph_stats_per_repo_test.go | 2 +- internal/mcp/index_health_empty_repo_test.go | 2 +- internal/mcp/instructions_test.go | 2 +- internal/mcp/search_corpus_scope_test.go | 2 +- internal/mcp/tools_review_rulepack_test.go | 2 +- internal/mcp/tools_search_text_pathsep_test.go | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/mcp/diff_repo_scope_test.go b/internal/mcp/diff_repo_scope_test.go index 169dddc59..4036d9199 100644 --- a/internal/mcp/diff_repo_scope_test.go +++ b/internal/mcp/diff_repo_scope_test.go @@ -43,7 +43,7 @@ func TestDiffRepoScope(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/freshness_rider_extractor_lang_test.go b/internal/mcp/freshness_rider_extractor_lang_test.go index 719a0f0a7..ab7c8c735 100644 --- a/internal/mcp/freshness_rider_extractor_lang_test.go +++ b/internal/mcp/freshness_rider_extractor_lang_test.go @@ -66,7 +66,7 @@ func TestFreshnessRider_ExtractorStalenessIsScopedToTheFilesLanguage(t *testing. store := &riderIndexStateStore{Store: g} mi := indexer.NewMultiIndexer(store, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/graph_stats_per_repo_test.go b/internal/mcp/graph_stats_per_repo_test.go index b2a3cd4f8..8690161bd 100644 --- a/internal/mcp/graph_stats_per_repo_test.go +++ b/internal/mcp/graph_stats_per_repo_test.go @@ -42,7 +42,7 @@ func setupMultiRepoStatsServer(t *testing.T) *Server { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } require.True(t, mi.IsMultiRepo()) diff --git a/internal/mcp/index_health_empty_repo_test.go b/internal/mcp/index_health_empty_repo_test.go index 3664677a4..090ace518 100644 --- a/internal/mcp/index_health_empty_repo_test.go +++ b/internal/mcp/index_health_empty_repo_test.go @@ -45,7 +45,7 @@ func healthServerWithIndexedRepos(t *testing.T, named map[string]string) *Server reg.Register(languages.NewGoExtractor()) mi := indexer.NewMultiIndexer(graph.New(), reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go index 480449c77..3447eea77 100644 --- a/internal/mcp/instructions_test.go +++ b/internal/mcp/instructions_test.go @@ -180,7 +180,7 @@ func TestStateAwareInstructionsVariants(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/search_corpus_scope_test.go b/internal/mcp/search_corpus_scope_test.go index 356e01ae4..786d7a26b 100644 --- a/internal/mcp/search_corpus_scope_test.go +++ b/internal/mcp/search_corpus_scope_test.go @@ -59,7 +59,7 @@ func newWorkspaceRootBoundServer(t *testing.T) (s *Server, root string) { reg.Register(languages.NewGoExtractor()) mi := indexer.NewMultiIndexer(store, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/tools_review_rulepack_test.go b/internal/mcp/tools_review_rulepack_test.go index c0e4fdfc0..d938323ca 100644 --- a/internal/mcp/tools_review_rulepack_test.go +++ b/internal/mcp/tools_review_rulepack_test.go @@ -52,7 +52,7 @@ func prefixedServerOver(t *testing.T, dir, name string) (*Server, string) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } diff --git a/internal/mcp/tools_search_text_pathsep_test.go b/internal/mcp/tools_search_text_pathsep_test.go index c2d0770ae..7d9e5989b 100644 --- a/internal/mcp/tools_search_text_pathsep_test.go +++ b/internal/mcp/tools_search_text_pathsep_test.go @@ -63,7 +63,7 @@ func nestedRepoServer(t *testing.T, entries []config.RepoEntry) (*Server, *graph g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) for _, entry := range cm.Global().ReposSnapshot() { - _, err := mi.TrackRepoCtx(context.Background(), entry.Path, entry.Name) + _, err := mi.TrackRepoCtx(context.Background(), entry) require.NoError(t, err) } From 1c06f897ebadf7baccb634a69b8ce3daae5e97a2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:54:21 +0100 Subject: [PATCH 08/34] test(startup): finalize contract fixtures through production registration --- .../mcp/repo_registration_fixture_test.go | 44 +++++++++++++++++++ internal/mcp/tools_contract_bridge_test.go | 3 +- internal/mcp/tools_contracts_filter_test.go | 7 ++- internal/mcp/tools_contracts_test.go | 6 +-- 4 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 internal/mcp/repo_registration_fixture_test.go diff --git a/internal/mcp/repo_registration_fixture_test.go b/internal/mcp/repo_registration_fixture_test.go new file mode 100644 index 000000000..1d5ea61c5 --- /dev/null +++ b/internal/mcp/repo_registration_fixture_test.go @@ -0,0 +1,44 @@ +package mcp + +import ( + "context" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" +) + +// registerMCPFixtureRepos uses the daemon's registration and shared deferred +// stages. Cross-repository queries need all roots published before resolution +// and contract matching; independent TrackRepo calls do not establish that +// boundary. These fixtures start empty and have no concurrent lifecycle writer. +func registerMCPFixtureRepos(t *testing.T, mi *indexer.MultiIndexer, cm *config.ConfigManager) { + t.Helper() + ctx := context.Background() + mi.BeginParallelBatch() + defer mi.ResetBatch() + entries := cm.ActiveRepos() + mi.ExpectRepos(entries) + var prefixes []string + for _, entry := range mi.ResolveConfiguredRepos(ctx, entries) { + mi.StartRepoLoad(entry.Path) + result, err := mi.TrackRepoCtx(ctx, entry) + mi.FinishRepoLoad(entry.Path, err) + require.NoError(t, err) + prefixes = append(prefixes, result.RepoPrefix) + } + require.NoError(t, mi.RunPreEnrichResolve(ctx, nil, nil)) + mi.RunDeferredPassesAllResult(ctx) + mi.ReconcileContractEdges() + // SQLite query fixtures also need their durable resolved-reference sidecar + // after the shared resolution, rather than after each individual root. + if rebuilder, ok := mi.Graph().(graph.RefFactsRebuilder); ok { + sort.Strings(prefixes) + require.NoError(t, rebuilder.RebuildRefFactsForRepos(prefixes)) + } + mi.EndBatch() +} diff --git a/internal/mcp/tools_contract_bridge_test.go b/internal/mcp/tools_contract_bridge_test.go index 7323dd1c7..fc37168fc 100644 --- a/internal/mcp/tools_contract_bridge_test.go +++ b/internal/mcp/tools_contract_bridge_test.go @@ -127,8 +127,7 @@ func fetchUsers() { g := graph.New() mi := indexer.NewMultiIndexer(g, preg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) return NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ diff --git a/internal/mcp/tools_contracts_filter_test.go b/internal/mcp/tools_contracts_filter_test.go index a61dd188e..b4e1836db 100644 --- a/internal/mcp/tools_contracts_filter_test.go +++ b/internal/mcp/tools_contracts_filter_test.go @@ -40,8 +40,8 @@ func TestHandleContracts_FiltersByProjectAndRef(t *testing.T) { orphanRepo := setupRepoWithHTTPConsumer(t, root, "orphan-svc") tmpCfg := filepath.Join(t.TempDir(), "config.yaml") - // Top-level Repos drives `mi.IndexAll()` so every repo actually - // gets walked; projects + refs are metadata used for filter + // Top-level Repos selects the startup registration fixtures; projects + // and refs are metadata used for filter // resolution only. Listing a repo in both places is valid — the // prefix resolves identically (it's derived from Name), so the // allow-set never double-counts. @@ -70,8 +70,7 @@ func TestHandleContracts_FiltersByProjectAndRef(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, preg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ diff --git a/internal/mcp/tools_contracts_test.go b/internal/mcp/tools_contracts_test.go index cffa49b20..fb1692a3b 100644 --- a/internal/mcp/tools_contracts_test.go +++ b/internal/mcp/tools_contracts_test.go @@ -92,8 +92,7 @@ func TestHandleContracts_ReflectsRuntimeTrackedRepos(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, preg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) singleton := indexer.New(g, preg, config.IndexConfig{}, zap.NewNop()) @@ -161,8 +160,7 @@ func TestHandleContracts_MatchesGraphContractCount(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, preg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ From 64a690996aacdd08590c1f7b7ac9490011021cf4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:54:32 +0100 Subject: [PATCH 09/34] test(startup): build retrieval fixtures through registered repositories --- internal/mcp/savings_retrieval_test.go | 3 +-- internal/mcp/tools_find_declaration_test.go | 3 +-- internal/mcp/tools_search_text_test.go | 12 ++++-------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/internal/mcp/savings_retrieval_test.go b/internal/mcp/savings_retrieval_test.go index 7f022784f..3fb58778a 100644 --- a/internal/mcp/savings_retrieval_test.go +++ b/internal/mcp/savings_retrieval_test.go @@ -68,8 +68,7 @@ func Widget%d%d(key string, depth int) (string, bool) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/tools_find_declaration_test.go b/internal/mcp/tools_find_declaration_test.go index 9cecd6569..86736e4be 100644 --- a/internal/mcp/tools_find_declaration_test.go +++ b/internal/mcp/tools_find_declaration_test.go @@ -251,8 +251,7 @@ func consumer() int { return uniqueDecl() + uniqueDecl() } reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) diff --git a/internal/mcp/tools_search_text_test.go b/internal/mcp/tools_search_text_test.go index afc65c869..dc57b5c23 100644 --- a/internal/mcp/tools_search_text_test.go +++ b/internal/mcp/tools_search_text_test.go @@ -182,8 +182,7 @@ func TestSearchText_MultiRepoFanout(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) @@ -238,8 +237,7 @@ func TestSearchText_MultiRepoScopedGrepDoesNotLetOutOfScopeRepoConsumeLimit(t *t reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) flagOn := true @@ -296,8 +294,7 @@ func TestFilterTextMatchesByResolvedScope_FailsClosed(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ @@ -366,8 +363,7 @@ func TestGetFileSummary_MultiRepoRelativePath(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) From ad94bd872d7f24467e5103093f3b09a5ef2d0e9d Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:54:32 +0100 Subject: [PATCH 10/34] test(startup): seed mutation and worktree fixtures through tracking --- internal/mcp/change_contract_edit_paths_test.go | 3 +-- internal/mcp/rename_recovery_test.go | 3 +-- internal/mcp/tools_core_index_test.go | 6 ++---- internal/mcp/tools_core_reindex_test.go | 9 +++------ internal/mcp/tools_fileops_worktree_test.go | 6 ++---- internal/mcp/tools_multi_worktree_test.go | 3 +-- 6 files changed, 10 insertions(+), 20 deletions(-) diff --git a/internal/mcp/change_contract_edit_paths_test.go b/internal/mcp/change_contract_edit_paths_test.go index 00a76746b..9605aeda4 100644 --- a/internal/mcp/change_contract_edit_paths_test.go +++ b/internal/mcp/change_contract_edit_paths_test.go @@ -107,8 +107,7 @@ func setupContractMultiRepoServer(t *testing.T, names ...string) (*Server, *grap g := graph.New() multi := indexer.NewMultiIndexer(g, testRegistry(), search.NewNull(), manager, zap.NewNop()) - _, err = multi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, multi, manager) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) srv.multiIndexer = multi diff --git a/internal/mcp/rename_recovery_test.go b/internal/mcp/rename_recovery_test.go index 98783f09f..36df9eac5 100644 --- a/internal/mcp/rename_recovery_test.go +++ b/internal/mcp/rename_recovery_test.go @@ -235,8 +235,7 @@ func setupMultiRepoRenameRecoveryServer( registry := testRegistry() store := graph.New() multi := indexer.NewMultiIndexer(store, registry, search.NewNull(), manager, zap.NewNop()) - _, err = multi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, multi, manager) var base *indexer.Indexer if baseCfg != nil { diff --git a/internal/mcp/tools_core_index_test.go b/internal/mcp/tools_core_index_test.go index b996f5d6e..4086fed6b 100644 --- a/internal/mcp/tools_core_index_test.go +++ b/internal/mcp/tools_core_index_test.go @@ -60,8 +60,7 @@ func TestHandleIndexRepository_MultiRepoRoutesThroughMultiIndexer(t *testing.T) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) // Invariant before: every node has a non-empty RepoPrefix, and both repos @@ -196,8 +195,7 @@ func TestHandleIndexRepository_MultiRepoRejectsUntrackedPath(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ diff --git a/internal/mcp/tools_core_reindex_test.go b/internal/mcp/tools_core_reindex_test.go index e8ee12caf..168d5cc6a 100644 --- a/internal/mcp/tools_core_reindex_test.go +++ b/internal/mcp/tools_core_reindex_test.go @@ -276,8 +276,7 @@ func TestHandleReindexRepository_MultiRepoRoutesByPrefix(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) @@ -334,8 +333,7 @@ func TestHandleReindexRepository_MultiRepoPathScoped(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ @@ -388,8 +386,7 @@ func TestHandleReindexRepository_MultiRepoRejectsUntrackedPath(t *testing.T) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ diff --git a/internal/mcp/tools_fileops_worktree_test.go b/internal/mcp/tools_fileops_worktree_test.go index 35f09d94b..28e94ff96 100644 --- a/internal/mcp/tools_fileops_worktree_test.go +++ b/internal/mcp/tools_fileops_worktree_test.go @@ -87,8 +87,7 @@ func setupWorktreePair(t *testing.T) (mainRepo, worktree string, srv *Server) { g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) require.True(t, mi.IsMultiRepo()) eng := query.NewEngine(g) @@ -420,8 +419,7 @@ func TestEditTools_UnprefixedPathAmbiguousAcrossTwoWorktrees(t *testing.T) { reg.Register(languages.NewGoExtractor()) g := graph.New() mi := indexer.NewMultiIndexer(g, reg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, MultiIndexer: mi, diff --git a/internal/mcp/tools_multi_worktree_test.go b/internal/mcp/tools_multi_worktree_test.go index 147b36fa6..0002af3df 100644 --- a/internal/mcp/tools_multi_worktree_test.go +++ b/internal/mcp/tools_multi_worktree_test.go @@ -35,8 +35,7 @@ func newWorktreeMCPServer(t *testing.T, repos ...config.RepoEntry) (*Server, *in preg := testRegistry() g := graph.New() mi := indexer.NewMultiIndexer(g, preg, search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexAll() - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) singleton := indexer.New(g, preg, config.IndexConfig{}, zap.NewNop()) From ef8376e1fa439e7f10a2d3251d368df9a09a7707 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 02:55:42 +0100 Subject: [PATCH 11/34] refactor(startup): remove obsolete production batch indexing path --- internal/graph/graph.go | 6 +- internal/indexer/cold_manifest_census_test.go | 2 +- internal/indexer/multi.go | 457 +---------------- .../indexer/multi_cold_orchestration_test.go | 16 +- internal/indexer/multi_fixture_test.go | 464 ++++++++++++++++++ internal/indexer/multi_scoped_test.go | 40 +- .../output_generation_authority_test.go | 10 +- .../repository_mutation_coordinator.go | 2 - internal/indexer/scope_for_cwd_test.go | 10 +- internal/indexer/vector_multirepo_test.go | 4 +- internal/indexer/vector_warm_route_test.go | 2 +- .../indexer/workspace_prefix_snapshot_test.go | 4 +- 12 files changed, 514 insertions(+), 503 deletions(-) create mode 100644 internal/indexer/multi_fixture_test.go diff --git a/internal/graph/graph.go b/internal/graph/graph.go index fc2f35183..10ebf5eaf 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -464,10 +464,8 @@ func removeEdgeFromBucket(bucket map[string][]*Edge, keys map[string][]edgeHash, // constructed against this Graph. Per-shard locks protect individual // node/edge writes, but resolution phases (ResolveAll, ResolveFile) // iterate every shard and mutate edge targets in place — two -// resolvers racing on the same edge produced the data race that -// MultiIndexer.indexMultiRepo triggered when its per-repo goroutines -// each created their own Resolver. Routing every resolver through -// this single mutex serialises those phases without blocking +// per-repository resolvers can race on the same shared edge. Routing every +// resolver through this single mutex serialises those phases without blocking // ordinary shard-scoped reads and writes. type Graph struct { // shards holds the lock-sharded node/edge maps. Its length is diff --git a/internal/indexer/cold_manifest_census_test.go b/internal/indexer/cold_manifest_census_test.go index 4ba7f20c7..deb84a138 100644 --- a/internal/indexer/cold_manifest_census_test.go +++ b/internal/indexer/cold_manifest_census_test.go @@ -777,7 +777,7 @@ func TestColdScopedDeferredTailPreservesUnselectedAttempt(t *testing.T) { // This is the actual cold orchestration entry whose sorted lane set only // contains "selected". The previously registered "overlap" repository's // lane is not part of this batch, despite its pending deferred contracts. - results, err := mi.indexMultiRepo([]config.RepoEntry{{Path: selectedRoot, Name: "selected"}}) + results, err := mi.indexFixtureRepos([]config.RepoEntry{{Path: selectedRoot, Name: "selected"}}) require.NoError(t, err) require.Contains(t, results, "selected") assert.Same(t, registry, unselected.pendingContractReg, diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 7e2e7cff9..82975a52e 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -267,12 +267,12 @@ type MultiIndexer struct { // every per-repo Indexer. When nil (the default), per-repo // deferred passes skip semantic enrichment — this is the root // cause of "enrich:0" in daemon mode. Set by the daemon via - // SetSemanticManager before IndexAll / TrackRepo. + // SetSemanticManager before TrackRepo. semanticMgr *semantic.Manager } // SetEmbedder installs the embedding provider every per-repo indexer -// should use. Must be called before IndexAll / TrackRepo for vectors +// should use. Must be called before TrackRepo for vectors // to land in the graph — without this the fresh Indexer created per // repo has embedder=nil and vector-plan preparation skips the embedding pass. // Safe to call zero or one times; subsequent calls silently replace. @@ -427,7 +427,7 @@ func (mi *MultiIndexer) newPerRepoIndexerGuardedWithMode( // SetEmbeddingChunkOptions installs the AST sub-chunking configuration // every per-repo Indexer this MultiIndexer constructs should use, and // re-applies it to every per-repo Indexer already built. Call before -// IndexAll / TrackRepo so the warmup indexers pick it up. +// TrackRepo so the warmup indexers pick it up. func (mi *MultiIndexer) SetEmbeddingChunkOptions(opts embedding.ChunkOptions) { mi.mu.Lock() mi.embedChunkOpts = opts @@ -625,16 +625,6 @@ func (mi *MultiIndexer) RunDeferredPassesAllResult(ctx context.Context) Deferred return mi.BeginDeferredPasses(ctx, nil).FinishTailResult() } -// finishColdDeferredPasses completes the cold-index deferred tail without -// repeating the whole-graph cross-repository resolver. The receipt-guided tail -// already performs either exact cross-repository catch-up or a fail-closed full -// pass; only contract-bridge reconciliation remains afterward. -func (mi *MultiIndexer) finishColdDeferredPasses(ctx context.Context, completedIndexers []*Indexer) DeferredPassesResult { - result := mi.beginDeferredPasses(ctx, nil, completedIndexers, true).FinishTailResult() - mi.ReconcileContractEdges() - return result -} - // current requires this Indexer's repository mutation lane to be held. func (a *deferredPassAttempt) current() bool { return a.indexer.deferredAttempt == a && a.indexer.pendingContractReg == a.registry @@ -2046,79 +2036,6 @@ func NewMultiIndexer( } } -// IndexAll indexes all active repos concurrently. Each repo gets its own -// Indexer instance with repo-specific config. Returns per-repo IndexResults. -func (mi *MultiIndexer) IndexAll() (map[string]*IndexResult, error) { - return mi.IndexScoped("", "") -} - -// IndexScoped is IndexAll restricted to repos whose workspace and -// project slugs match. Empty filters disable that axis (so empty/empty -// is equivalent to IndexAll). Resolution honours the same precedence -// as resolveWorkspaceID/resolveProjectID — RepoEntry override → -// `.gortex.yaml::workspace` → repo prefix — so a `gortex server -// --workspace foo` invocation matches both repos that declare -// `workspace: foo` in their own `.gortex.yaml` and repos pinned to -// `foo` from the user's global config. -// -// Returns an error when the filters exclude every active repo, so a -// `--workspace typo` surfaces as a startup failure rather than a -// silently empty graph. -func (mi *MultiIndexer) IndexScoped(workspaceSlug, projectSlug string) (map[string]*IndexResult, error) { - repos := mi.configMgr.ActiveRepos() - if len(repos) == 0 { - return nil, nil - } - if workspaceSlug != "" || projectSlug != "" { - filtered := mi.filterReposByScope(repos, workspaceSlug, projectSlug) - if len(filtered) == 0 { - return nil, fmt.Errorf("scope filter matched zero of %d active repos (workspace=%q project=%q)", len(repos), workspaceSlug, projectSlug) - } - repos = filtered - } - - // indexMultiRepo owns the complete coordinated pipeline, including the - // one contract reconciliation required before graph-wide derivation. Do - // not repeat it here: reconciliation evicts and rebuilds a generation and - // a second identical pass is pure global work. - // - // A lone repo takes the same path as any other — it is simply the first - // tracked repo, and its nodes carry its prefix like everyone else's. - return mi.indexMultiRepo(repos) -} - -// filterReposByScope returns the subset of repos whose resolved -// workspace and project slugs match the supplied filters. Empty -// filters disable that axis. Loads each repo's `.gortex.yaml` first so -// resolution sees the workspace/project declared there — matching only -// against `RepoEntry.Workspace` would miss repos that declare their -// slug in their own config file (the typical case for first-party -// repos). -func (mi *MultiIndexer) filterReposByScope(repos []config.RepoEntry, workspaceSlug, projectSlug string) []config.RepoEntry { - if workspaceSlug == "" && projectSlug == "" { - return repos - } - out := make([]config.RepoEntry, 0, len(repos)) - for _, e := range repos { - absPath, err := filepath.Abs(e.Path) - if err != nil { - continue - } - prefix := config.ResolvePrefix(e) - mi.configMgr.LoadWorkspaceConfig(prefix, absPath) - cfg := mi.configMgr.GetRepoConfig(prefix) - entryCopy := e - if workspaceSlug != "" && resolveWorkspaceID(&entryCopy, cfg, prefix) != workspaceSlug { - continue - } - if projectSlug != "" && resolveProjectID(&entryCopy, cfg, prefix) != projectSlug { - continue - } - out = append(out, e) - } - return out -} - func (idx *Indexer) installGoResolverContext() { mi := idx.repositoryMutationOwner if mi == nil { @@ -2264,370 +2181,6 @@ func readGoModModule(repoPath string) string { return modfile.ModulePath(data) } -// rebuildColdRefFacts seeds the durable resolved-reference sidecar once for -// the complete successful repository set. The backend implementation is a -// single set-oriented transaction; keeping the capability probe here avoids a -// per-repository persistence loop and leaves in-memory stores as a no-op. -func (mi *MultiIndexer) rebuildColdRefFacts(ctx context.Context, repoPrefixes []string) error { - if err := ctx.Err(); err != nil { - return err - } - rebuilder, ok := mi.graph.(graph.RefFactsRebuilder) - if !ok { - return nil - } - if err := rebuilder.RebuildRefFactsForRepos(repoPrefixes); err != nil { - return err - } - // The current backend contract is atomic but not interruptible in flight. - // Observing cancellation here prevents completion/global consumers from - // publishing a generation whose ref-fact boundary the caller abandoned. - return ctx.Err() -} - -// indexMultiRepo indexes multiple repos concurrently with repo prefixes. -func (mi *MultiIndexer) indexMultiRepo(repos []config.RepoEntry) (map[string]*IndexResult, error) { - type repoResult struct { - prefix string - result *IndexResult - idx *Indexer - meta *RepoMetadata - err error - } - - // Resolve each repo's prefix serially first: git-worktree instancing, - // the derived-name persistence, and the cross-batch collision guard - // must be deterministic, not raced across the parallel index - // goroutines below. The same loop builds the tracked-module map used - // for cross-repo dependency detection. - type resolvedRepo struct { - entry config.RepoEntry - prefix string - cfg *config.Config - absPath string - identity *RepoIdentity - } - var configLookup config.RepoPathLookup - if mi.configMgr != nil { - configLookup = mi.configMgr.Global().SnapshotRepoPaths() - } - moduleRoots := make(map[string]string) - resolved := make([]resolvedRepo, 0, len(repos)) - seenPrefix := make(map[string]string, len(repos)) // prefix → absPath - var resolveErrors []string - for _, entry := range repos { - absPath, err := filepath.Abs(entry.Path) - if err != nil { - resolveErrors = append(resolveErrors, fmt.Sprintf("resolving path %s: %v", entry.Path, err)) - continue - } - info, err := os.Stat(absPath) - if err != nil { - resolveErrors = append(resolveErrors, fmt.Sprintf("opening repository %s: %v", entry.Path, err)) - continue - } - if !info.IsDir() { - resolveErrors = append(resolveErrors, fmt.Sprintf("opening repository %s: not a directory", entry.Path)) - continue - } - identity, _ := DetectIdentity(absPath) - e := entry - prefix, cfg := mi.resolveTrackPrefix(&e, absPath, identity, configLookup, mi.configuredPrefixCheck()) - // Two distinct checkouts can still land on the same derived prefix - // (e.g. two worktrees declaring the same workspace). resolveTrackPrefix - // only sees already-tracked repos, so guard against collisions within - // this batch too. - if prev, ok := seenPrefix[prefix]; ok { - if pathkey.SamePathIdentity(prev, absPath) { - // Duplicate config entries for one repository must not launch - // concurrent raw workers behind a single held repository lane. - continue - } - prefix += "-" + shortPathHash(absPath) - e.Name = prefix - // This prefix is minted for THIS batch and is not the one the - // lifecycle registered an owner under, so the receipt opened for - // it below finds no owner and the repository is left unwitnessed - // (openSourceWitness). That is the honest direction — the nodes - // this pass writes carry the disambiguated prefix too, so no pin - // taken on the colliding prefix is reading them — but a request - // against the disambiguated prefix gets ErrBaseCorpusUnwitnessed - // rather than an exactness label until an owner is registered for - // the name this batch chose. - } - seenPrefix[prefix] = absPath - resolved = append(resolved, resolvedRepo{entry: e, prefix: prefix, cfg: cfg, absPath: absPath, identity: identity}) - moduleRoots[prefix] = absPath - } - trackedModules := readTrackedGoModules(moduleRoots) - for prefix, mod := range trackedModules { - mi.logger.Debug("tracked repo module", zap.String("repo", prefix), zap.String("module", mod)) - } - - if len(resolved) == 0 { - if len(resolveErrors) == 0 { - return map[string]*IndexResult{}, nil - } - sort.Strings(resolveErrors) - return nil, fmt.Errorf("all repos failed to index: %s", strings.Join(resolveErrors, "; ")) - } - - prefixes := make([]string, 0, len(resolved)) - for _, repo := range resolved { - prefixes = append(prefixes, repo.prefix) - } - // Freeze one batch-mode generation before acquiring any stable repository - // lane. This global gate -> sorted lanes order prevents a queued transition - // writer from owning the gate while waiting for a lane held by this batch. - mi.batchMutationGate.RLock() - defer mi.batchMutationGate.RUnlock() - - // One receipt per OUTPUT OWNER this batch writes, opened after every lane - // is held and before any payload moves. The whole batch is one decision: if - // a newer mutation takes over any of these owners while it runs, the batch - // cannot fulfil that owner's generation. - // - // Owners are deduplicated, and not by prefix. Two tracked entries can name - // one repository root (two config entries with different explicit Names and - // one Path: resolveTrackPrefix honours an explicit Name verbatim, so the - // prefix collision guard above never fires). legacyOutputTargetFor keys the - // owner on the root, so both entries name ONE generation-zero output. - // Issuing two receipts for it would make the second supersede the first and - // refuse a cold index that fully succeeded. - batchTargets := make([]OutputMutationTarget, 0, len(resolved)) - seenOutputOwner := make(map[string]bool, len(resolved)) - for _, repo := range resolved { - target := legacyOutputTargetFor(outputStoreIdentity(mi.graph), repo.prefix, repo.absPath, "prefix:"+repo.prefix) - if seenOutputOwner[target.OwnerKey] { - continue - } - seenOutputOwner[target.OwnerKey] = true - batchTargets = append(batchTargets, target) - } - - var finalResults map[string]*IndexResult - laneErr := mi.withRepositoryMutationLanes(context.Background(), prefixes, func() error { - receipts, receiptErr := mi.outputGenerationAuthority().BeginAll( - context.Background(), OutputEntryIndexMultiRepo, batchTargets) - if receiptErr != nil { - return receiptErr - } - settled := false - defer func() { - if !settled { - receipts.Abandon() - } - }() - // Preventive half: refuse before the cold batch writes anything if any - // owner was already taken over while admission blocked on its gates. - if err := receipts.Current(); err != nil { - return err - } - batchErr := func() error { - // The one topology writer covers parse, publication, deferred/cross-repo - // tails, ref-facts, and global derivation. - batchMode := mi.currentBatchMode() - finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) - defer finishTopologyMutation(true) - - pipelineResults, pipelineErr := func() (map[string]*IndexResult, error) { - resultCh := make(chan repoResult, len(resolved)) - var wg sync.WaitGroup - coordinatedBulk, _ := mi.graph.(graph.CoordinatedBulkLoader) - coordinatedBulkActive := coordinatedBulk != nil && coordinatedBulk.BeginCoordinatedBulkLoad() - defer func() { - if coordinatedBulkActive { - if err := coordinatedBulk.EndCoordinatedBulkLoad(); err != nil { - mi.logger.Error("multi-repo bulk-load cleanup failed", zap.Error(err)) - } - } - }() - - for _, rr := range resolved { - wg.Add(1) - go func(r resolvedRepo) { - defer wg.Done() - - idx := mi.newPerRepoIndexerGuardedWithMode(r.cfg.Index, batchMode) - idx.SetRepoPrefix(r.prefix) - entryCopy := r.entry - idx.SetWorkspaceID(resolveWorkspaceID(&entryCopy, r.cfg, r.prefix)) - idx.SetProjectID(resolveProjectID(&entryCopy, r.cfg, r.prefix)) - idx.SetTrackedRepoModules(trackedModules) - // Defer the per-repo cross-cutting passes (ResolveAll, - // semantic enrich, contract extract+commit) so they don't - // race against each other across goroutines on the shared - // graph. They run serially below via RunDeferredPasses after - // wg.Wait(). The graph-wide derivation passes run once after - // the loop via the shared global-pass pipeline. - idx.SetDeferResolve(true) - - result, err := idx.indexCtxRaw(WithIndexOrigin(context.Background(), "index_all"), r.absPath) - if err != nil { - idx.Close() - resultCh <- repoResult{prefix: r.prefix, err: fmt.Errorf("indexing %s: %w", r.absPath, err)} - return - } - if result == nil { - idx.Close() - resultCh <- repoResult{prefix: r.prefix, err: fmt.Errorf("indexing %s returned a nil result", r.absPath)} - return - } - result.RepoPrefix = r.prefix - - meta := &RepoMetadata{ - RepoPrefix: r.prefix, - RootPath: r.absPath, - Identity: r.identity, - LastIndexTime: time.Now(), - FileCount: result.FileCount, - NodeCount: result.NodeCount, - EdgeCount: result.EdgeCount, - ParseErrors: result.Errors, - FileMtimes: idx.publishFileMtimes(), - - IsWorktree: ResolveWorktree(r.absPath).IsWorktree, - } - - resultCh <- repoResult{prefix: r.prefix, result: result, idx: idx, meta: meta} - }(rr) - } - - go func() { - wg.Wait() - close(resultCh) - }() - - results := make(map[string]*IndexResult) - indexErrors := resolveErrors - completed := make([]repoResult, 0, len(resolved)) - - // Drain workers without holding the registry lock. Constructors and future - // worker tails may need read access to MultiIndexer state; pinning mi.mu - // across the channel range turns any such read into a producer/consumer - // deadlock and blocks unrelated registry readers for the whole warmup. - for rr := range resultCh { - if rr.err != nil { - mi.logger.Error("failed to index repo", zap.String("prefix", rr.prefix), zap.Error(rr.err)) - indexErrors = append(indexErrors, rr.err.Error()) - continue - } - completed = append(completed, rr) - results[rr.prefix] = rr.result - } - oldIndexers := make([]*Indexer, 0, len(completed)) - mi.mu.Lock() - for _, rr := range completed { - if old := mi.indexers[rr.prefix]; old != nil && old != rr.idx { - oldIndexers = append(oldIndexers, old) - } - mi.repos[rr.prefix] = rr.meta - mi.indexers[rr.prefix] = rr.idx - } - mi.mu.Unlock() - for _, old := range oldIndexers { - old.Close() - } - if coordinatedBulkActive { - if err := coordinatedBulk.EndCoordinatedBulkLoad(); err != nil { - return nil, fmt.Errorf("multi-repo bulk-load finalize: %w", err) - } - coordinatedBulkActive = false - } - for _, rr := range completed { - if err := mi.postPublishGoModules(context.Background(), rr.idx, true); err != nil { - mi.logger.Warn("post-publish Go module follow-up", zap.Error(err)) - } - } - - // Do not publish a completed pipeline when no repository reached the - // deferred stages. Besides making the failure deterministic, this prevents - // global passes from deriving edges from a partially-drained failed batch. - if len(indexErrors) > 0 && len(results) == 0 { - sort.Strings(indexErrors) - return nil, fmt.Errorf("all repos failed to index: %s", strings.Join(indexErrors, "; ")) - } - - // Complete cold multi-repo indexing through the same coordinated pipeline - // used by daemon warmup: - // 1. materialise go.mod contracts once, then run one shared base resolve; - // 2. enrich repositories in bounded language-aware batches (large Go - // repositories remain exclusive), committing contracts only after each - // batch drains; - // 3. use the mutation receipt to perform only the exact catch-up needed for - // semantic/contract mutations. - // - // The old loop called idx.RunDeferredPasses with - // skipResolveInDeferred=false, so every repository performed ResolveAll over - // the entire shared graph. At R repositories and E edges that was O(R*E) and - // was the dominant cold-index regression. runDeferredGoMod is generation- - // idempotent, so RunDeferredPassesAll does not repeat the pre-resolve work. - deferCtx := context.Background() - if err := mi.RunPreEnrichResolve(deferCtx, nil, nil); err != nil { - return results, fmt.Errorf("multi-repo pre-enrichment resolve: %w", err) - } - completedIndexers := make([]*Indexer, 0, len(completed)) - for _, rr := range completed { - completedIndexers = append(completedIndexers, rr.idx) - } - deferredResult := mi.finishColdDeferredPasses(deferCtx, completedIndexers) - mi.logger.Info("multi-repo coordinated deferred passes complete", - zap.Int("repos_indexed", len(results)), - zap.Int("repos_failed", len(indexErrors)), - zap.Int("enrich_scheduled", deferredResult.EnrichScheduled), - zap.Bool("exact_cross_repo_complete", deferredResult.ExactCrossRepoComplete)) - - // ResolveAll normally seeds ref_facts after a full resolve. The coordinated - // cold path intentionally bypasses per-repository ResolveAll, so seed the - // successful repository set once after every base, semantic catch-up, and - // cross-repository mutation has settled. Sorting makes the boundary stable - // for tracing/tests; SQLite consumes the whole slice in one transaction. - successfulPrefixes := make([]string, 0, len(results)) - for prefix := range results { - successfulPrefixes = append(successfulPrefixes, prefix) - } - sort.Strings(successfulPrefixes) - if err := mi.rebuildColdRefFacts(deferCtx, successfulPrefixes); err != nil { - return results, fmt.Errorf("multi-repo reference-fact rebuild: %w", err) - } - - // Graph-wide derivation passes run exactly once after every repo - // has been parsed, every per-repo and cross-repo resolver has lifted - // placeholder edges, and contract bridges are in place. RunDeferredPasses - // intentionally skips these so we don't pay an O(global) walk per - // repo (was the dominant cost at R≈100+). - mi.runGlobalGraphPassesTopologyHeld(context.Background(), nil, false) - - return results, nil - }() - finalResults = pipelineResults - return pipelineErr - }() - if batchErr != nil { - return batchErr - } - settled = true - // Fulfilment is refused when a newer mutation took over any of these - // owners while the batch ran; the batch then reports that refusal - // instead of announcing a completed cold index. - if err := receipts.Complete(); err != nil { - return err - } - // Every completed repository's journal is accepted, failed files - // included: the cold pass staged a failure envelope for each of them - // (indexCtxRaw), so the accepted namespace excludes exactly that set. - for prefix, result := range finalResults { - if result != nil { - if err := mi.acceptInstalledContractCoreInputs(context.Background(), prefix); err != nil { - return err - } - } - } - return nil - }) - return finalResults, laneErr -} - // IndexRepo re-indexes a single repo by prefix. Evicts existing data first. func (mi *MultiIndexer) IndexRepo(repoPrefix string) (*IndexResult, error) { mi.mu.RLock() @@ -2704,8 +2257,8 @@ func (mi *MultiIndexer) indexRepoRaw(repoPrefix string) (*IndexResult, error) { } }() // Always stamp the repo prefix, even when this is the only tracked repo. - // The multi-repo cold path (indexMultiRepo) already prefixes - // unconditionally; gating the single-repo re-index on repo count left the + // First registration also prefixes unconditionally; gating re-indexing + // on repo count left the // two paths producing different id / path shapes for the same repo, so // adding a second repo turned the first from bare into prefixed — a lossy // whole-graph rewrite. Uniform prefixing removes the single/multi data-shape diff --git a/internal/indexer/multi_cold_orchestration_test.go b/internal/indexer/multi_cold_orchestration_test.go index 28bc6ba3a..71f0fa479 100644 --- a/internal/indexer/multi_cold_orchestration_test.go +++ b/internal/indexer/multi_cold_orchestration_test.go @@ -98,7 +98,7 @@ func TestFinishColdDeferredPassesDoesNotRepeatFullCrossRepoResolve(t *testing.T) store := &coldOrchestrationProbeStore{Graph: graph.New()} mi := newColdOrchestrationMulti(store, zap.NewNop()) - result := mi.finishColdDeferredPasses(t.Context(), nil) + result := mi.finishFixtureDeferredPasses(t.Context(), nil) assert.True(t, result.ExactCrossRepoComplete) assert.Zero(t, store.unresolvedScans.Load(), @@ -112,7 +112,7 @@ func TestIndexMultiRepoUsesOneCoordinatedBaseResolve(t *testing.T) { mi := newColdOrchestrationMulti(graph.New(), zap.New(core)) repos := coldOrchestrationRepos(t, 8) - results, err := mi.indexMultiRepo(repos) + results, err := mi.indexFixtureRepos(repos) require.NoError(t, err) require.Len(t, results, len(repos)) @@ -139,7 +139,7 @@ func TestIndexMultiRepoAllFailuresDoNotRunCompletionPipeline(t *testing.T) { }) } - results, err := mi.indexMultiRepo(repos) + results, err := mi.indexFixtureRepos(repos) require.Error(t, err) assert.Nil(t, results) assert.Empty(t, logs.FilterMessage("DEFERRED-TIMING master.ResolveAll").All()) @@ -332,7 +332,7 @@ func snapshotColdGraph(store graph.Store, repos []config.RepoEntry) coldGraphSha func TestCoordinatedColdPipelineMatchesSerialReferenceGraph(t *testing.T) { repos := coldOrchestrationRepos(t, 5) coordinated := newColdOrchestrationMulti(graph.New(), zap.NewNop()) - _, err := coordinated.indexMultiRepo(repos) + _, err := coordinated.indexFixtureRepos(repos) require.NoError(t, err) serial := serialColdReference(t, repos) @@ -407,7 +407,7 @@ func TestIndexMultiRepoRefFactsMatchesSingleFullAndUsesOneSetRebuild(t *testing. counting := &coldRefFactsCountingStore{Store: store} multi := newColdOrchestrationMulti(counting, zap.NewNop()) - results, err := multi.indexMultiRepo(requested) + results, err := multi.indexFixtureRepos(requested) require.NoError(t, err) require.Len(t, results, len(repos)) require.Equal(t, 1, counting.rebuildCalls, @@ -449,11 +449,11 @@ func TestColdRefFactRebuildPreservesFailureAndCancellation(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() - err := mi.rebuildColdRefFacts(ctx, []string{"repo-a", "repo-b"}) + err := mi.rebuildFixtureRefFacts(ctx, []string{"repo-a", "repo-b"}) require.ErrorIs(t, err, context.Canceled) require.Zero(t, probe.rebuildCalls, "cancellation before the boundary must skip the rebuild") - err = mi.rebuildColdRefFacts(t.Context(), []string{"repo-a", "repo-b"}) + err = mi.rebuildFixtureRefFacts(t.Context(), []string{"repo-a", "repo-b"}) require.ErrorIs(t, err, sentinel) require.Equal(t, 1, probe.rebuildCalls) require.Equal(t, []string{"repo-a", "repo-b"}, probe.rebuildPrefixes) @@ -465,7 +465,7 @@ func TestIndexMultiRepoRefFactFailureStopsGlobalConsumers(t *testing.T) { probe := &coldRefFactsProbeStore{Store: graph.New(), rebuildErr: sentinel} mi := newColdOrchestrationMulti(probe, zap.New(core)) - results, err := mi.indexMultiRepo(coldRefFactRepos(t, 2)) + results, err := mi.indexFixtureRepos(coldRefFactRepos(t, 2)) require.ErrorIs(t, err, sentinel) require.Len(t, results, 2, "successful parse results remain available with the completion error") require.Equal(t, 1, probe.rebuildCalls) diff --git a/internal/indexer/multi_fixture_test.go b/internal/indexer/multi_fixture_test.go new file mode 100644 index 000000000..2b21c49cf --- /dev/null +++ b/internal/indexer/multi_fixture_test.go @@ -0,0 +1,464 @@ +package indexer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/pathkey" + "github.com/zzet/gortex/internal/reach" +) + +// These fixtures retain the atomic cold-cohort shape used by lane, topology, +// output-generation and resolver tests. They are deliberately test-only: +// production startup registers roots through TrackRepoCtx and coordinates its +// deferred stages in the daemon. External fixtures use that production path. +// The cohort fixture names the existing topology output entry, not a separate +// production mutation door. +// finishFixtureDeferredPasses completes the cold-index deferred tail without +// repeating the whole-graph cross-repository resolver. The receipt-guided tail +// already performs either exact cross-repository catch-up or a fail-closed full +// pass; only contract-bridge reconciliation remains afterward. +func (mi *MultiIndexer) finishFixtureDeferredPasses(ctx context.Context, completedIndexers []*Indexer) DeferredPassesResult { + result := mi.beginDeferredPasses(ctx, nil, completedIndexers, true).FinishTailResult() + mi.ReconcileContractEdges() + return result +} + +// IndexAll indexes all active repos concurrently. Each repo gets its own +// Indexer instance with repo-specific config. Returns per-repo IndexResults. +func (mi *MultiIndexer) IndexAll() (map[string]*IndexResult, error) { + return mi.indexFixtureScope("", "") +} + +// indexFixtureScope selects fixture roots with the same workspace/project +// precedence as registered repositories: entry override, workspace config, +// then prefix. Empty filters retain every active fixture root; a typo refuses +// an empty test graph. This is not a production startup selection API. +func (mi *MultiIndexer) indexFixtureScope(workspaceSlug, projectSlug string) (map[string]*IndexResult, error) { + repos := mi.configMgr.ActiveRepos() + if len(repos) == 0 { + return nil, nil + } + if workspaceSlug != "" || projectSlug != "" { + filtered := mi.filterFixtureReposByScope(repos, workspaceSlug, projectSlug) + if len(filtered) == 0 { + return nil, fmt.Errorf("scope filter matched zero of %d active repos (workspace=%q project=%q)", len(repos), workspaceSlug, projectSlug) + } + repos = filtered + } + + // indexFixtureRepos owns the complete coordinated pipeline, including the + // one contract reconciliation required before graph-wide derivation. Do + // not repeat it here: reconciliation evicts and rebuilds a generation and + // a second identical pass is pure global work. + // + // A lone repo takes the same path as any other — it is simply the first + // tracked repo, and its nodes carry its prefix like everyone else's. + return mi.indexFixtureRepos(repos) +} + +// filterFixtureReposByScope returns the subset of repos whose resolved +// workspace and project slugs match the supplied filters. Empty +// filters disable that axis. Loads each repo's `.gortex.yaml` first so +// resolution sees the workspace/project declared there — matching only +// against `RepoEntry.Workspace` would miss repos that declare their +// slug in their own config file (the typical case for first-party +// repos). +func (mi *MultiIndexer) filterFixtureReposByScope(repos []config.RepoEntry, workspaceSlug, projectSlug string) []config.RepoEntry { + if workspaceSlug == "" && projectSlug == "" { + return repos + } + out := make([]config.RepoEntry, 0, len(repos)) + for _, e := range repos { + absPath, err := filepath.Abs(e.Path) + if err != nil { + continue + } + prefix := config.ResolvePrefix(e) + mi.configMgr.LoadWorkspaceConfig(prefix, absPath) + cfg := mi.configMgr.GetRepoConfig(prefix) + entryCopy := e + if workspaceSlug != "" && resolveWorkspaceID(&entryCopy, cfg, prefix) != workspaceSlug { + continue + } + if projectSlug != "" && resolveProjectID(&entryCopy, cfg, prefix) != projectSlug { + continue + } + out = append(out, e) + } + return out +} + +// rebuildFixtureRefFacts seeds the durable resolved-reference sidecar once for +// the complete successful repository set. The backend implementation is a +// single set-oriented transaction; keeping the capability probe here avoids a +// per-repository persistence loop and leaves in-memory stores as a no-op. +func (mi *MultiIndexer) rebuildFixtureRefFacts(ctx context.Context, repoPrefixes []string) error { + if err := ctx.Err(); err != nil { + return err + } + rebuilder, ok := mi.graph.(graph.RefFactsRebuilder) + if !ok { + return nil + } + if err := rebuilder.RebuildRefFactsForRepos(repoPrefixes); err != nil { + return err + } + // The current backend contract is atomic but not interruptible in flight. + // Observing cancellation here prevents completion/global consumers from + // publishing a generation whose ref-fact boundary the caller abandoned. + return ctx.Err() +} + +// indexFixtureRepos indexes multiple repos concurrently with repo prefixes. +func (mi *MultiIndexer) indexFixtureRepos(repos []config.RepoEntry) (map[string]*IndexResult, error) { + type repoResult struct { + prefix string + result *IndexResult + idx *Indexer + meta *RepoMetadata + err error + } + + // Resolve each repo's prefix serially first: git-worktree instancing, + // the derived-name persistence, and the cross-batch collision guard + // must be deterministic, not raced across the parallel index + // goroutines below. The same loop builds the tracked-module map used + // for cross-repo dependency detection. + type resolvedRepo struct { + entry config.RepoEntry + prefix string + cfg *config.Config + absPath string + identity *RepoIdentity + } + var configLookup config.RepoPathLookup + if mi.configMgr != nil { + configLookup = mi.configMgr.Global().SnapshotRepoPaths() + } + moduleRoots := make(map[string]string) + resolved := make([]resolvedRepo, 0, len(repos)) + seenPrefix := make(map[string]string, len(repos)) // prefix → absPath + var resolveErrors []string + for _, entry := range repos { + absPath, err := filepath.Abs(entry.Path) + if err != nil { + resolveErrors = append(resolveErrors, fmt.Sprintf("resolving path %s: %v", entry.Path, err)) + continue + } + info, err := os.Stat(absPath) + if err != nil { + resolveErrors = append(resolveErrors, fmt.Sprintf("opening repository %s: %v", entry.Path, err)) + continue + } + if !info.IsDir() { + resolveErrors = append(resolveErrors, fmt.Sprintf("opening repository %s: not a directory", entry.Path)) + continue + } + identity, _ := DetectIdentity(absPath) + e := entry + prefix, cfg := mi.resolveTrackPrefix(&e, absPath, identity, configLookup, mi.configuredPrefixCheck()) + // Two distinct checkouts can still land on the same derived prefix + // (e.g. two worktrees declaring the same workspace). resolveTrackPrefix + // only sees already-tracked repos, so guard against collisions within + // this batch too. + if prev, ok := seenPrefix[prefix]; ok { + if pathkey.SamePathIdentity(prev, absPath) { + // Duplicate config entries for one repository must not launch + // concurrent raw workers behind a single held repository lane. + continue + } + prefix += "-" + shortPathHash(absPath) + e.Name = prefix + // This prefix is minted for THIS batch and is not the one the + // lifecycle registered an owner under, so the receipt opened for + // it below finds no owner and the repository is left unwitnessed + // (openSourceWitness). That is the honest direction — the nodes + // this pass writes carry the disambiguated prefix too, so no pin + // taken on the colliding prefix is reading them — but a request + // against the disambiguated prefix gets ErrBaseCorpusUnwitnessed + // rather than an exactness label until an owner is registered for + // the name this batch chose. + } + seenPrefix[prefix] = absPath + resolved = append(resolved, resolvedRepo{entry: e, prefix: prefix, cfg: cfg, absPath: absPath, identity: identity}) + moduleRoots[prefix] = absPath + } + trackedModules := readTrackedGoModules(moduleRoots) + for prefix, mod := range trackedModules { + mi.logger.Debug("tracked repo module", zap.String("repo", prefix), zap.String("module", mod)) + } + + if len(resolved) == 0 { + if len(resolveErrors) == 0 { + return map[string]*IndexResult{}, nil + } + sort.Strings(resolveErrors) + return nil, fmt.Errorf("all repos failed to index: %s", strings.Join(resolveErrors, "; ")) + } + + prefixes := make([]string, 0, len(resolved)) + for _, repo := range resolved { + prefixes = append(prefixes, repo.prefix) + } + // Freeze one batch-mode generation before acquiring any stable repository + // lane. This global gate -> sorted lanes order prevents a queued transition + // writer from owning the gate while waiting for a lane held by this batch. + mi.batchMutationGate.RLock() + defer mi.batchMutationGate.RUnlock() + + // One receipt per OUTPUT OWNER this batch writes, opened after every lane + // is held and before any payload moves. The whole batch is one decision: if + // a newer mutation takes over any of these owners while it runs, the batch + // cannot fulfil that owner's generation. + // + // Owners are deduplicated, and not by prefix. Two tracked entries can name + // one repository root (two config entries with different explicit Names and + // one Path: resolveTrackPrefix honours an explicit Name verbatim, so the + // prefix collision guard above never fires). legacyOutputTargetFor keys the + // owner on the root, so both entries name ONE generation-zero output. + // Issuing two receipts for it would make the second supersede the first and + // refuse a cold index that fully succeeded. + batchTargets := make([]OutputMutationTarget, 0, len(resolved)) + seenOutputOwner := make(map[string]bool, len(resolved)) + for _, repo := range resolved { + target := legacyOutputTargetFor(outputStoreIdentity(mi.graph), repo.prefix, repo.absPath, "prefix:"+repo.prefix) + if seenOutputOwner[target.OwnerKey] { + continue + } + seenOutputOwner[target.OwnerKey] = true + batchTargets = append(batchTargets, target) + } + + var finalResults map[string]*IndexResult + laneErr := mi.withRepositoryMutationLanes(context.Background(), prefixes, func() error { + receipts, receiptErr := mi.outputGenerationAuthority().BeginAll( + context.Background(), OutputEntryRepositoryTopology, batchTargets) + if receiptErr != nil { + return receiptErr + } + settled := false + defer func() { + if !settled { + receipts.Abandon() + } + }() + // Preventive half: refuse before the cold batch writes anything if any + // owner was already taken over while admission blocked on its gates. + if err := receipts.Current(); err != nil { + return err + } + batchErr := func() error { + // The one topology writer covers parse, publication, deferred/cross-repo + // tails, ref-facts, and global derivation. + batchMode := mi.currentBatchMode() + finishTopologyMutation := reach.BeginTopologyMutation(mi.graph) + defer finishTopologyMutation(true) + + pipelineResults, pipelineErr := func() (map[string]*IndexResult, error) { + resultCh := make(chan repoResult, len(resolved)) + var wg sync.WaitGroup + coordinatedBulk, _ := mi.graph.(graph.CoordinatedBulkLoader) + coordinatedBulkActive := coordinatedBulk != nil && coordinatedBulk.BeginCoordinatedBulkLoad() + defer func() { + if coordinatedBulkActive { + if err := coordinatedBulk.EndCoordinatedBulkLoad(); err != nil { + mi.logger.Error("multi-repo bulk-load cleanup failed", zap.Error(err)) + } + } + }() + + for _, rr := range resolved { + wg.Add(1) + go func(r resolvedRepo) { + defer wg.Done() + + idx := mi.newPerRepoIndexerGuardedWithMode(r.cfg.Index, batchMode) + idx.SetRepoPrefix(r.prefix) + entryCopy := r.entry + idx.SetWorkspaceID(resolveWorkspaceID(&entryCopy, r.cfg, r.prefix)) + idx.SetProjectID(resolveProjectID(&entryCopy, r.cfg, r.prefix)) + idx.SetTrackedRepoModules(trackedModules) + // Defer the per-repo cross-cutting passes (ResolveAll, + // semantic enrich, contract extract+commit) so they don't + // race against each other across goroutines on the shared + // graph. They run serially below via RunDeferredPasses after + // wg.Wait(). The graph-wide derivation passes run once after + // the loop via the shared global-pass pipeline. + idx.SetDeferResolve(true) + + result, err := idx.indexCtxRaw(WithIndexOrigin(context.Background(), "index_all"), r.absPath) + if err != nil { + idx.Close() + resultCh <- repoResult{prefix: r.prefix, err: fmt.Errorf("indexing %s: %w", r.absPath, err)} + return + } + if result == nil { + idx.Close() + resultCh <- repoResult{prefix: r.prefix, err: fmt.Errorf("indexing %s returned a nil result", r.absPath)} + return + } + result.RepoPrefix = r.prefix + + meta := &RepoMetadata{ + RepoPrefix: r.prefix, + RootPath: r.absPath, + Identity: r.identity, + LastIndexTime: time.Now(), + FileCount: result.FileCount, + NodeCount: result.NodeCount, + EdgeCount: result.EdgeCount, + ParseErrors: result.Errors, + FileMtimes: idx.publishFileMtimes(), + + IsWorktree: ResolveWorktree(r.absPath).IsWorktree, + } + + resultCh <- repoResult{prefix: r.prefix, result: result, idx: idx, meta: meta} + }(rr) + } + + go func() { + wg.Wait() + close(resultCh) + }() + + results := make(map[string]*IndexResult) + indexErrors := resolveErrors + completed := make([]repoResult, 0, len(resolved)) + + // Drain workers without holding the registry lock. Constructors and future + // worker tails may need read access to MultiIndexer state; pinning mi.mu + // across the channel range turns any such read into a producer/consumer + // deadlock and blocks unrelated registry readers for the whole warmup. + for rr := range resultCh { + if rr.err != nil { + mi.logger.Error("failed to index repo", zap.String("prefix", rr.prefix), zap.Error(rr.err)) + indexErrors = append(indexErrors, rr.err.Error()) + continue + } + completed = append(completed, rr) + results[rr.prefix] = rr.result + } + oldIndexers := make([]*Indexer, 0, len(completed)) + mi.mu.Lock() + for _, rr := range completed { + if old := mi.indexers[rr.prefix]; old != nil && old != rr.idx { + oldIndexers = append(oldIndexers, old) + } + mi.repos[rr.prefix] = rr.meta + mi.indexers[rr.prefix] = rr.idx + } + mi.mu.Unlock() + for _, old := range oldIndexers { + old.Close() + } + if coordinatedBulkActive { + if err := coordinatedBulk.EndCoordinatedBulkLoad(); err != nil { + return nil, fmt.Errorf("multi-repo bulk-load finalize: %w", err) + } + coordinatedBulkActive = false + } + for _, rr := range completed { + if err := mi.postPublishGoModules(context.Background(), rr.idx, true); err != nil { + mi.logger.Warn("post-publish Go module follow-up", zap.Error(err)) + } + } + + // Do not publish a completed pipeline when no repository reached the + // deferred stages. Besides making the failure deterministic, this prevents + // global passes from deriving edges from a partially-drained failed batch. + if len(indexErrors) > 0 && len(results) == 0 { + sort.Strings(indexErrors) + return nil, fmt.Errorf("all repos failed to index: %s", strings.Join(indexErrors, "; ")) + } + + // Complete cold multi-repo indexing through the same coordinated pipeline + // used by daemon warmup: + // 1. materialise go.mod contracts once, then run one shared base resolve; + // 2. enrich repositories in bounded language-aware batches (large Go + // repositories remain exclusive), committing contracts only after each + // batch drains; + // 3. use the mutation receipt to perform only the exact catch-up needed for + // semantic/contract mutations. + // + // The old loop called idx.RunDeferredPasses with + // skipResolveInDeferred=false, so every repository performed ResolveAll over + // the entire shared graph. At R repositories and E edges that was O(R*E) and + // was the dominant cold-index regression. runDeferredGoMod is generation- + // idempotent, so RunDeferredPassesAll does not repeat the pre-resolve work. + deferCtx := context.Background() + if err := mi.RunPreEnrichResolve(deferCtx, nil, nil); err != nil { + return results, fmt.Errorf("multi-repo pre-enrichment resolve: %w", err) + } + completedIndexers := make([]*Indexer, 0, len(completed)) + for _, rr := range completed { + completedIndexers = append(completedIndexers, rr.idx) + } + deferredResult := mi.finishFixtureDeferredPasses(deferCtx, completedIndexers) + mi.logger.Info("multi-repo coordinated deferred passes complete", + zap.Int("repos_indexed", len(results)), + zap.Int("repos_failed", len(indexErrors)), + zap.Int("enrich_scheduled", deferredResult.EnrichScheduled), + zap.Bool("exact_cross_repo_complete", deferredResult.ExactCrossRepoComplete)) + + // ResolveAll normally seeds ref_facts after a full resolve. The coordinated + // cold path intentionally bypasses per-repository ResolveAll, so seed the + // successful repository set once after every base, semantic catch-up, and + // cross-repository mutation has settled. Sorting makes the boundary stable + // for tracing/tests; SQLite consumes the whole slice in one transaction. + successfulPrefixes := make([]string, 0, len(results)) + for prefix := range results { + successfulPrefixes = append(successfulPrefixes, prefix) + } + sort.Strings(successfulPrefixes) + if err := mi.rebuildFixtureRefFacts(deferCtx, successfulPrefixes); err != nil { + return results, fmt.Errorf("multi-repo reference-fact rebuild: %w", err) + } + + // Graph-wide derivation passes run exactly once after every repo + // has been parsed, every per-repo and cross-repo resolver has lifted + // placeholder edges, and contract bridges are in place. RunDeferredPasses + // intentionally skips these so we don't pay an O(global) walk per + // repo (was the dominant cost at R≈100+). + mi.runGlobalGraphPassesTopologyHeld(context.Background(), nil, false) + + return results, nil + }() + finalResults = pipelineResults + return pipelineErr + }() + if batchErr != nil { + return batchErr + } + settled = true + // Fulfilment is refused when a newer mutation took over any of these + // owners while the batch ran; the batch then reports that refusal + // instead of announcing a completed cold index. + if err := receipts.Complete(); err != nil { + return err + } + // Every completed repository's journal is accepted, failed files + // included: the cold pass staged a failure envelope for each of them + // (indexCtxRaw), so the accepted namespace excludes exactly that set. + for prefix, result := range finalResults { + if result != nil { + if err := mi.acceptInstalledContractCoreInputs(context.Background(), prefix); err != nil { + return err + } + } + } + return nil + }) + return finalResults, laneErr +} diff --git a/internal/indexer/multi_scoped_test.go b/internal/indexer/multi_scoped_test.go index fc0281449..e019b7cc4 100644 --- a/internal/indexer/multi_scoped_test.go +++ b/internal/indexer/multi_scoped_test.go @@ -14,11 +14,11 @@ import ( "github.com/zzet/gortex/internal/search" ) -// TestMultiIndexer_IndexScoped_WorkspaceFromGortexYAML verifies that -// IndexScoped picks up the workspace slug declared in a repo's own +// TestMultiIndexer_FixtureScope_WorkspaceFromGortexYAML verifies that +// indexFixtureScope picks up the workspace slug declared in a repo's own // `.gortex.yaml` — the typical first-party setup. Repos with a // different workspace slug must be excluded. -func TestMultiIndexer_IndexScoped_WorkspaceFromGortexYAML(t *testing.T) { +func TestMultiIndexer_FixtureScope_WorkspaceFromGortexYAML(t *testing.T) { repoA := setupRepoDir(t, "repo-a") repoB := setupRepoDir(t, "repo-b") require.NoError(t, os.WriteFile(filepath.Join(repoA, ".gortex.yaml"), @@ -42,7 +42,7 @@ func TestMultiIndexer_IndexScoped_WorkspaceFromGortexYAML(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - results, err := mi.IndexScoped("alpha", "") + results, err := mi.indexFixtureScope("alpha", "") require.NoError(t, err) require.Len(t, results, 1) assert.Contains(t, results, "repo-a") @@ -50,11 +50,11 @@ func TestMultiIndexer_IndexScoped_WorkspaceFromGortexYAML(t *testing.T) { assert.Nil(t, mi.GetMetadata("repo-b")) } -// TestMultiIndexer_IndexScoped_RepoEntryOverride verifies that a +// TestMultiIndexer_FixtureScope_RepoEntryOverride verifies that a // global-config RepoEntry.Workspace override beats the repo's own // `.gortex.yaml::workspace` slug — the user pinning OSS / read-only // repos into a workspace without leaving an artifact in them. -func TestMultiIndexer_IndexScoped_RepoEntryOverride(t *testing.T) { +func TestMultiIndexer_FixtureScope_RepoEntryOverride(t *testing.T) { repoA := setupRepoDir(t, "repo-a") require.NoError(t, os.WriteFile(filepath.Join(repoA, ".gortex.yaml"), []byte("workspace: upstream\n"), 0o644)) @@ -75,19 +75,19 @@ func TestMultiIndexer_IndexScoped_RepoEntryOverride(t *testing.T) { mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) // Override wins over .gortex.yaml: "upstream" matches nothing. - _, err = mi.IndexScoped("upstream", "") + _, err = mi.indexFixtureScope("upstream", "") require.Error(t, err) // "local-pin" matches the override. - results, err := mi.IndexScoped("local-pin", "") + results, err := mi.indexFixtureScope("local-pin", "") require.NoError(t, err) require.Len(t, results, 1) } -// TestMultiIndexer_IndexScoped_FallsBackToRepoPrefix verifies the +// TestMultiIndexer_FixtureScope_FallsBackToRepoPrefix verifies the // default: repos with no `.gortex.yaml::workspace` and no // RepoEntry.Workspace use the repo prefix as their workspace slug. -func TestMultiIndexer_IndexScoped_FallsBackToRepoPrefix(t *testing.T) { +func TestMultiIndexer_FixtureScope_FallsBackToRepoPrefix(t *testing.T) { repoA := setupRepoDir(t, "repo-a") repoB := setupRepoDir(t, "repo-b") @@ -107,17 +107,17 @@ func TestMultiIndexer_IndexScoped_FallsBackToRepoPrefix(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - results, err := mi.IndexScoped("repo-b", "") + results, err := mi.indexFixtureScope("repo-b", "") require.NoError(t, err) require.Len(t, results, 1) assert.Contains(t, results, "repo-b") } -// TestMultiIndexer_IndexScoped_ProjectNarrowsInsideWorkspace verifies +// TestMultiIndexer_FixtureScope_ProjectNarrowsInsideWorkspace verifies // that workspace + project filters are AND-combined. Two repos in the // same workspace but different projects → only the matching project // gets indexed. -func TestMultiIndexer_IndexScoped_ProjectNarrowsInsideWorkspace(t *testing.T) { +func TestMultiIndexer_FixtureScope_ProjectNarrowsInsideWorkspace(t *testing.T) { repoA := setupRepoDir(t, "repo-a") repoB := setupRepoDir(t, "repo-b") require.NoError(t, os.WriteFile(filepath.Join(repoA, ".gortex.yaml"), @@ -141,16 +141,16 @@ func TestMultiIndexer_IndexScoped_ProjectNarrowsInsideWorkspace(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - results, err := mi.IndexScoped("shared", "api") + results, err := mi.indexFixtureScope("shared", "api") require.NoError(t, err) require.Len(t, results, 1) assert.Contains(t, results, "repo-a") } -// TestMultiIndexer_IndexScoped_NoMatchErrors verifies that a typo'd +// TestMultiIndexer_FixtureScope_NoMatchErrors verifies that a typo'd // filter surfaces as an error rather than producing a silent empty // graph. -func TestMultiIndexer_IndexScoped_NoMatchErrors(t *testing.T) { +func TestMultiIndexer_FixtureScope_NoMatchErrors(t *testing.T) { repoA := setupRepoDir(t, "repo-a") tmpCfg := filepath.Join(t.TempDir(), "config.yaml") @@ -166,16 +166,16 @@ func TestMultiIndexer_IndexScoped_NoMatchErrors(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("nonexistent", "") + _, err = mi.indexFixtureScope("nonexistent", "") require.Error(t, err) assert.Contains(t, err.Error(), "scope filter matched zero") assert.Equal(t, 0, g.NodeCount(), "no nodes should be added on a zero-match scope") } -// TestMultiIndexer_IndexScoped_EmptyFiltersEqualsIndexAll verifies +// TestMultiIndexer_FixtureScope_EmptyFiltersEqualsIndexAll verifies // that empty/empty filters degrade to IndexAll behaviour: every // active repo is indexed. -func TestMultiIndexer_IndexScoped_EmptyFiltersEqualsIndexAll(t *testing.T) { +func TestMultiIndexer_FixtureScope_EmptyFiltersEqualsIndexAll(t *testing.T) { repoA := setupRepoDir(t, "repo-a") repoB := setupRepoDir(t, "repo-b") require.NoError(t, os.WriteFile(filepath.Join(repoA, ".gortex.yaml"), @@ -197,7 +197,7 @@ func TestMultiIndexer_IndexScoped_EmptyFiltersEqualsIndexAll(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - results, err := mi.IndexScoped("", "") + results, err := mi.indexFixtureScope("", "") require.NoError(t, err) assert.Len(t, results, 2) assert.Contains(t, results, "repo-a") diff --git a/internal/indexer/output_generation_authority_test.go b/internal/indexer/output_generation_authority_test.go index 1e6a0e747..2db72513f 100644 --- a/internal/indexer/output_generation_authority_test.go +++ b/internal/indexer/output_generation_authority_test.go @@ -298,7 +298,6 @@ func TestEveryProductionMutationEntryPointIsRegistered(t *testing.T) { // same function still names an entry. required := []mutationDoor{ {file: "contract_baseline_reconcile.go", fn: "reconcilePrimaryContractBaseline", door: "withRepositoryMutationLanes", entries: []string{"OutputEntryContractBaseline"}}, - {file: "multi.go", fn: "indexMultiRepo", door: "withRepositoryMutationLanes", entries: []string{"OutputEntryIndexMultiRepo"}}, {file: "multi.go", fn: "IndexRepo", door: "runExclusive", entries: []string{"OutputEntryIndexRepo"}}, {file: "multi.go", fn: "incrementalDiscoverRepo", door: "runExclusive", entries: []string{"OutputEntryIncrementalDiscoverRepo"}}, {file: "repository_topology_batch.go", fn: "coordinateRepositoryTopologyMutation", door: "runExclusiveLaneOnly", entries: []string{"OutputEntryRepositoryTopology"}}, @@ -407,7 +406,6 @@ func outputMutationEntryValue(identifier string) (string, bool) { "OutputEntryReresolveFileScoped": OutputEntryReresolveFileScoped, "OutputEntryDeferredPasses": OutputEntryDeferredPasses, "OutputEntryRepositoryTopology": OutputEntryRepositoryTopology, - "OutputEntryIndexMultiRepo": OutputEntryIndexMultiRepo, "OutputEntryIndexRepo": OutputEntryIndexRepo, "OutputEntryIncrementalDiscoverRepo": OutputEntryIncrementalDiscoverRepo, "OutputEntryGitWatcherFinalize": OutputEntryGitWatcherFinalize, @@ -619,7 +617,7 @@ func TestSupersededWorkIsRefusedBeforeItWrites(t *testing.T) { } // The batch form refuses the same way, before the batch writes anything. - batchOlder, err := authority.Begin(ctx, OutputEntryIndexMultiRepo, target) + batchOlder, err := authority.Begin(ctx, OutputEntryRepositoryTopology, target) if err != nil { t.Fatalf("batch receipt: %v", err) } @@ -627,7 +625,7 @@ func TestSupersededWorkIsRefusedBeforeItWrites(t *testing.T) { if err := batch.Current(); err != nil { t.Fatalf("an uncontested batch must be current: %v", err) } - contender, err := authority.Begin(ctx, OutputEntryIndexMultiRepo, target) + contender, err := authority.Begin(ctx, OutputEntryRepositoryTopology, target) if err != nil { t.Fatalf("batch contender: %v", err) } @@ -1429,9 +1427,9 @@ func TestColdBatchOpensOneReceiptPerRepositoryOwner(t *testing.T) { t.Fatalf("IndexAll: %v", err) } stats := authority.Stats() - if got := stats.Entries[OutputEntryIndexMultiRepo]; got != 2 { + if got := stats.Entries[OutputEntryRepositoryTopology]; got != 2 { t.Fatalf("the cold batch opened %d receipts under %s, want one per repository owner (2)", - got, OutputEntryIndexMultiRepo) + got, OutputEntryRepositoryTopology) } if stats.Superseded != 0 || stats.Settled != stats.Issued { t.Fatalf("every batch receipt must settle cleanly: %+v", stats) diff --git a/internal/indexer/repository_mutation_coordinator.go b/internal/indexer/repository_mutation_coordinator.go index 094e2a3e4..bdcd03f29 100644 --- a/internal/indexer/repository_mutation_coordinator.go +++ b/internal/indexer/repository_mutation_coordinator.go @@ -1041,7 +1041,6 @@ const ( // lane worker, which names OutputEntryRepositoryReconcileLane below. OutputEntryDeferredPasses OutputMutationEntry = "indexer.MultiIndexer.withDeferredRepositoryMutation" OutputEntryRepositoryTopology OutputMutationEntry = "indexer.MultiIndexer.coordinateRepositoryTopologyMutation" - OutputEntryIndexMultiRepo OutputMutationEntry = "indexer.MultiIndexer.indexMultiRepo" OutputEntryIndexRepo OutputMutationEntry = "indexer.MultiIndexer.IndexRepo" OutputEntryIncrementalDiscoverRepo OutputMutationEntry = "indexer.MultiIndexer.incrementalDiscoverRepo" OutputEntryGitWatcherFinalize OutputMutationEntry = "indexer.GitWatcher.finalizeReconcile" @@ -1092,7 +1091,6 @@ var outputMutationEntryKinds = map[OutputMutationEntry]OutputGenerationKind{ OutputEntryReresolveFileScoped: OutputGenerationLegacy, OutputEntryDeferredPasses: OutputGenerationLegacy, OutputEntryRepositoryTopology: OutputGenerationLegacy, - OutputEntryIndexMultiRepo: OutputGenerationLegacy, OutputEntryIndexRepo: OutputGenerationLegacy, OutputEntryIncrementalDiscoverRepo: OutputGenerationLegacy, OutputEntryGitWatcherFinalize: OutputGenerationLegacy, diff --git a/internal/indexer/scope_for_cwd_test.go b/internal/indexer/scope_for_cwd_test.go index f77cfa75f..c5e32f5f3 100644 --- a/internal/indexer/scope_for_cwd_test.go +++ b/internal/indexer/scope_for_cwd_test.go @@ -49,7 +49,7 @@ func TestScopeForCWD_And_ReposInWorkspace(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") // empty scope → index every configured repo + _, err = mi.indexFixtureScope("", "") // empty scope → index every configured repo require.NoError(t, err) // cwd inside repo-a → workspace "alpha", home repo "repo-a". @@ -236,7 +236,7 @@ func TestScopeForCWD_WorkspaceRoot(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") + _, err = mi.indexFixtureScope("", "") require.NoError(t, err) // Workspace root above exactly one tracked repo: binds with the @@ -321,7 +321,7 @@ func TestContainedReposScope(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") + _, err = mi.indexFixtureScope("", "") require.NoError(t, err) // ScopeForCWD still fails closed on the mixed parent — this change @@ -403,7 +403,7 @@ func TestContainedReposScope_RefusesOverbroadRoots(t *testing.T) { g := graph.New() mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") + _, err = mi.indexFixtureScope("", "") require.NoError(t, err) // Precondition: a genuine parent inside home still binds, so the @@ -455,7 +455,7 @@ func TestScopeForCWD_RefusesOverbroadRootSharedWorkspace(t *testing.T) { require.NoError(t, err) mi := NewMultiIndexer(graph.New(), newTestRegistry(), search.NewNull(), cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") + _, err = mi.indexFixtureScope("", "") require.NoError(t, err) // Precondition: the shared slug makes the reverse-containment arm diff --git a/internal/indexer/vector_multirepo_test.go b/internal/indexer/vector_multirepo_test.go index c40a81da6..c1484030f 100644 --- a/internal/indexer/vector_multirepo_test.go +++ b/internal/indexer/vector_multirepo_test.go @@ -39,7 +39,7 @@ func TestIndexMultiRepoPublishesCombinedDurableVectorCorpus(t *testing.T) { mi := NewMultiIndexer(store, coldOrchestrationRegistry(), sw, nil, zap.NewNop()) mi.SetEmbedder(&poolEmbedder{}) - results, err := mi.indexMultiRepo(repos) + results, err := mi.indexFixtureRepos(repos) require.NoError(t, err) require.Len(t, results, len(repos)) stats, err := store.VectorCorpusStats(context.Background(), 3) @@ -64,7 +64,7 @@ func TestUntrackRepoRemovesVectorsAndRepublishesAggregateStats(t *testing.T) { sw := search.NewSwappable(initialSearchBackend(store)) mi := NewMultiIndexer(store, coldOrchestrationRegistry(), sw, cm, zap.NewNop()) mi.SetEmbedder(&poolEmbedder{}) - _, err = mi.indexMultiRepo(repos) + _, err = mi.indexFixtureRepos(repos) require.NoError(t, err) prefixA := config.ResolvePrefix(repos[0]) diff --git a/internal/indexer/vector_warm_route_test.go b/internal/indexer/vector_warm_route_test.go index 8fd12b506..fe9a7fb80 100644 --- a/internal/indexer/vector_warm_route_test.go +++ b/internal/indexer/vector_warm_route_test.go @@ -36,7 +36,7 @@ func TestWarmScopedReconcileRestoresDurableVectorCorpus(t *testing.T) { seed := NewMultiIndexer(graph.Store(store), newTestRegistry(), seedSw, cm, zap.NewNop()) seedEmb := &poolEmbedder{} seed.SetEmbedder(seedEmb) - results, err := seed.indexMultiRepo([]config.RepoEntry{entry}) + results, err := seed.indexFixtureRepos([]config.RepoEntry{entry}) require.NoError(t, err) require.Len(t, results, 1) require.Greater(t, seedEmb.calls, int32(0)) diff --git a/internal/indexer/workspace_prefix_snapshot_test.go b/internal/indexer/workspace_prefix_snapshot_test.go index 3dd37e0b2..c625d776c 100644 --- a/internal/indexer/workspace_prefix_snapshot_test.go +++ b/internal/indexer/workspace_prefix_snapshot_test.go @@ -16,11 +16,11 @@ import ( func TestIndexMultiRepoWithoutConfigManager(t *testing.T) { mi := newColdOrchestrationMulti(graph.New(), zap.NewNop()) t.Cleanup(func() { require.NoError(t, mi.Close(context.Background())) }) - results, err := mi.indexMultiRepo(nil) + results, err := mi.indexFixtureRepos(nil) require.NoError(t, err) assert.Empty(t, results) repos := coldOrchestrationRepos(t, 1) - results, err = mi.indexMultiRepo(repos) + results, err = mi.indexFixtureRepos(repos) require.NoError(t, err) require.Len(t, results, 1) assert.NotNil(t, mi.GetMetadata(config.ResolvePrefix(repos[0]))) From d10e885ad3762958c2d40ea1ea929fa9c3ca1413 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 03:07:43 +0100 Subject: [PATCH 12/34] test(startup): bound nonterminal CLI loading over the real socket --- cmd/gortex/cli_loading_socket_test.go | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 cmd/gortex/cli_loading_socket_test.go diff --git a/cmd/gortex/cli_loading_socket_test.go b/cmd/gortex/cli_loading_socket_test.go new file mode 100644 index 000000000..50d01764f --- /dev/null +++ b/cmd/gortex/cli_loading_socket_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/testenv" +) + +// Exercise the real CLI routing/probe/MCP/error path with piped stdout. Process +// startup is deliberately outside this bound; the runtime probes also measure +// the complete installed CLI invocation on the reference host. +func TestNonTTYCLI_LoadingSocketReturnsTempFailWithin100ms(t *testing.T) { + dispatcher, mi, _, loading := loadingDispatcherSetup(t) + controller := probeController(t, fmt.Sprintf("repos:\n - path: %q\n", loading)) + controller.multiIndexer = mi + controller.graph = mi.Graph() + dir := testenv.ShortTempDir(t) + socket := filepath.Join(dir, "s") + t.Setenv("GORTEX_DAEMON_SOCKET", socket) + t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p")) + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(dir, "state.json")) + t.Setenv("GORTEX_AUTOSTART", "0") + srv := daemon.New(socket, "test", zap.NewNop()) + srv.Controller = controller + srv.MCPDispatcher = dispatcher + require.NoError(t, srv.Listen()) + done := make(chan error, 1) + go func() { done <- srv.Serve() }() + t.Cleanup(func() { + require.NoError(t, srv.Shutdown()) + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Error("owned CLI loading server did not stop") + } + }) + + reader, writer, err := os.Pipe() + require.NoError(t, err) + stdout := os.Stdout + os.Stdout = writer + flag := rootCmd.PersistentFlags().Lookup("loading-wait") + changed := flag.Changed + flag.Changed = false + t.Cleanup(func() { + os.Stdout = stdout + flag.Changed = changed + _ = writer.Close() + _ = reader.Close() + }) + require.Zero(t, effectiveLoadingWait(), "piped CLI stdout must not use the interactive wait") + + started := time.Now() + _, err = requireDaemonTool(loading, "search_symbols", map[string]any{"query": "LoadProbe"}) + elapsed := time.Since(started) + var coded interface{ ExitCode() int } + require.ErrorAs(t, err, &coded) + assert.Equal(t, exitTempFail, coded.ExitCode(), "execute must propagate the loading exit contract") + assert.LessOrEqual(t, elapsed, 100*time.Millisecond) + assert.Contains(t, err.Error(), "still loading") + assert.NotContains(t, err.Error(), "gortex track") + assert.NotContains(t, err.Error(), "repos reconcile") +} From ff19a804262e620415e2b9f1cccf05db8653f64f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 04:58:05 +0100 Subject: [PATCH 13/34] test(startup): cover registration retry for 24 checkout coordinators --- internal/indexer/repo_load_state_test.go | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/internal/indexer/repo_load_state_test.go b/internal/indexer/repo_load_state_test.go index 23b4a3dc2..fe48a6ab8 100644 --- a/internal/indexer/repo_load_state_test.go +++ b/internal/indexer/repo_load_state_test.go @@ -3,6 +3,7 @@ package indexer import ( "context" "errors" + "fmt" "os" "path/filepath" "sync/atomic" @@ -251,6 +252,75 @@ func TestPrimaryRegisteredStartsTheCoordinatorsThatWaitedForIt(t *testing.T) { 5*time.Second, 20*time.Millisecond, "the primary's registration must start the coordinator that waited for it") } +// A selected restart cohort must all be retried by registration, rather than +// only the first checkout noticed before the primary's index was registered. +// These are real Git worktrees and durable catalog identities; this test is not +// the separate reference-host warm-restart workload or its latency evidence. +func TestPrimaryRegisteredStartsAll24WaitingCoordinators(t *testing.T) { + f := newLifecycleFixture(t) + defer f.close() + ctx := context.Background() + main := f.gitRepo("await-cohort-main") + const wanted = 24 + for i := range wanted { + f.worktreeOf(main, fmt.Sprintf("await-cohort-%02d", i)) + } + tracked, err := f.lc.Register(ctx, config.RepoEntry{Path: main, Name: "await-cohort-main"}, TrackSourceCLI) + require.NoError(t, err) + require.NoError(t, tracked.CatalogErr) + + f.restart() + require.NoError(t, f.lc.Seed(ctx)) + require.Nil(t, f.mi.GetMetadata(tracked.Prefix)) + checkouts, err := f.catalog.ListCheckouts(ctx, tracked.FamilyID) + require.NoError(t, err) + require.Len(t, checkouts, wanted+1, "the primary and every linked checkout must survive restart") + for _, checkout := range checkouts { + if checkout.CheckoutID == tracked.CheckoutID { + continue + } + // The same startup edge as an activation reaching ensureCoordinator: + // the catalog identity exists, but its primary is not registered yet. + f.lc.ensureCoordinator(ctx, tracked.GraphID, checkout) + require.False(t, f.lc.hasCoordinator(checkout.CheckoutID)) + } + f.lc.awaitingPrimaryMu.Lock() + waiting := len(f.lc.awaitingPrimary[tracked.GraphID]) + f.lc.awaitingPrimaryMu.Unlock() + require.Equal(t, wanted, waiting) + + f.lc.PrimaryRegistered("unrelated-primary") + require.Zero(t, f.lc.LiveCoordinators(tracked.FamilyID), + "another repository's event must not wake this cohort") + registered := make(chan time.Time, 1) + f.mi.OnRepoRegistered(func(rec RepoLoad) { + if rec.Prefix == tracked.Prefix { + registered <- time.Now() + } + f.lc.PrimaryRegistered(rec.Prefix) + }) + _, err = f.mi.TrackRepoCtx(ctx, config.RepoEntry{Path: main, Name: tracked.Prefix}) + require.NoError(t, err) + var registeredAt time.Time + select { + case registeredAt = <-registered: + case <-time.After(2 * time.Second): + t.Fatal("the real registration event did not drive the retry") + } + remaining := time.Until(registeredAt.Add(2 * time.Second)) + require.Positive(t, remaining, "the retry must start within the registration budget") + require.Eventually(t, func() bool { + return f.lc.LiveCoordinators(tracked.FamilyID) == wanted + }, remaining, 5*time.Millisecond, + "all waiting build loops must be live without a janitor sweep") + t.Logf("%d/%d coordinators live %s after primary registration", wanted, wanted, time.Since(registeredAt)) + assert.Empty(t, f.lc.CoordinatorStartFailures()) + f.lc.awaitingPrimaryMu.Lock() + waiting = len(f.lc.awaitingPrimary[tracked.GraphID]) + f.lc.awaitingPrimaryMu.Unlock() + assert.Zero(t, waiting, "successful retries must drain the waiting cohort") +} + func TestDeclareRepos_DefersFilesystemIdentityUntilWarmup(t *testing.T) { mi := newRepoLoadTestMulti(t) missing := filepath.Join(t.TempDir(), "not-mounted") From 7251b344e17ff84366b04e84919b6322e818cd93 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 05:08:18 +0100 Subject: [PATCH 14/34] test(startup): exercise MCP through a held SQLite migration --- cmd/gortex/ensure_daemon.go | 21 +- cmd/gortex/migration_window_proxy_test.go | 292 ++++++++++++++++++++++ 2 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 cmd/gortex/migration_window_proxy_test.go diff --git a/cmd/gortex/ensure_daemon.go b/cmd/gortex/ensure_daemon.go index ae03ff208..de04a3f50 100644 --- a/cmd/gortex/ensure_daemon.go +++ b/cmd/gortex/ensure_daemon.go @@ -23,16 +23,19 @@ const ( daemonUnavailable // autostart off, or spawn failed/timed out ) +// spawnLockInactivityTimeout is deliberately an inactivity budget, not a +// total startup budget. The lock winner holds the spawn lock until the +// detached child's socket is live; a schema migration can legitimately keep +// that socket closed for longer than a minute. Fresh PID-bound startup state +// extends this deadline in the same way spawnDetachedDaemon extends its own +// wait, while a missing/stale heartbeat still releases callers promptly. +// Tests shorten it only in an isolated helper process; there is no user +// configuration or environment override for this production budget. +var spawnLockInactivityTimeout = 60 * time.Second + const ( - // spawnLockInactivityTimeout is deliberately an inactivity budget, not a - // total startup budget. The lock winner holds the spawn lock until the - // detached child's socket is live; a schema migration can legitimately keep - // that socket closed for longer than a minute. Fresh PID-bound startup state - // extends this deadline in the same way spawnDetachedDaemon extends its own - // wait, while a missing/stale heartbeat still releases callers promptly. - spawnLockInactivityTimeout = 60 * time.Second - spawnLockPollInterval = 100 * time.Millisecond - spawnProgressFreshness = 10 * time.Second + spawnLockPollInterval = 100 * time.Millisecond + spawnProgressFreshness = 10 * time.Second // spawnFailCooldown bounds serial retries of a broken spawn: within // the window at most one spawn attempt runs across contending callers. spawnFailCooldown = 5 * time.Second diff --git a/cmd/gortex/migration_window_proxy_test.go b/cmd/gortex/migration_window_proxy_test.go new file mode 100644 index 000000000..808decf1b --- /dev/null +++ b/cmd/gortex/migration_window_proxy_test.go @@ -0,0 +1,292 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/gofrs/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph/store_sqlite" + gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/testenv" +) + +const migrationProxyTestInactivity = 50 * time.Millisecond + +type migrationProxyWaitWitness struct { + At time.Time `json:"at"` + Checks int `json:"checks"` + LockContended bool `json:"lock_contended"` + State daemon.RuntimeState `json:"state"` +} + +// This helper runs the real MCP selection and proxy in a fresh process. Only +// that process shortens the private inactivity budget. Its liveness seam still +// returns the actual socket result; the extra lock/heartbeat reads only record +// that it reached the contended wait before the parent begins its hold. +func TestMigrationWindowMCPProxyHelper(t *testing.T) { + if os.Getenv("GORTEX_TEST_MIGRATION_PROXY") != "1" { + t.Skip("invoked only by TestMCPWaitsThroughMigrationWindow") + } + spawnLockInactivityTimeout = migrationProxyTestInactivity + witnessDir := os.Getenv("GORTEX_TEST_MIGRATION_WITNESS") + require.NotEmpty(t, witnessDir) + checks := 0 + isDaemonRunning = func() bool { + running := daemon.IsRunning() + checks++ + if !running && checks >= 2 { + probe := flock.New(daemon.SpawnLockPath()) + locked, err := probe.TryLock() + require.NoError(t, err) + require.NoError(t, probe.Close()) + if locked { + return running // the parent may just have released startup + } + state, ok := daemon.ReadRuntimeState() + if !ok || state.StartupPhase != daemon.StartupMigrating { + return running // migration may have completed before Listen + } + require.True(t, state.StartupProgressFresh(time.Now(), spawnProgressFreshness)) + w := migrationProxyWaitWitness{At: time.Now(), Checks: checks, LockContended: true, State: state} + body, err := json.Marshal(w) + require.NoError(t, err) + path := filepath.Join(witnessDir, "latest.json") + if checks == 2 { + path = filepath.Join(witnessDir, "first.json") + } + require.NoError(t, os.WriteFile(path+".tmp", body, 0o600)) + require.NoError(t, os.Rename(path+".tmp", path)) + } + return running + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + mcpCmd.SetContext(ctx) + require.NoError(t, runMCP(mcpCmd, nil)) +} + +// This exercises an actual pending SQLite migration, heartbeat file, spawn +// lock, runMCP selection, stdio relay and public loading reply. The short hold +// proves renewal from a fresh PID-bound startup record, not the reporter's +// two-second periodic heartbeat. Historical schema-v21 workload acceptance is +// separate; this fixture deliberately replays the idempotent v40 step. +func TestMCPWaitsThroughMigrationWindow(t *testing.T) { + base := testenv.ShortTempDir(t) + testenv.SandboxAt(t, filepath.Join(base, "home")) + t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(base, "s")) + t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(base, "p")) + t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(base, "state.json")) + t.Setenv("GORTEX_AUTOSTART", "1") + t.Setenv("GORTEX_TELEMETRY", "0") + t.Setenv("GORTEX_TOOLS", "") + root := filepath.Join(base, "repo") + require.NoError(t, os.MkdirAll(root, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "main.go"), []byte("package p\nfunc MigrationProbe() {}\n"), 0o600)) + globalPath := config.DefaultGlobalConfigPath() + require.NoError(t, os.MkdirAll(filepath.Dir(globalPath), 0o700)) + // Opting into the fallback makes a premature embedded selection visible; + // it must not replace the configured daemon during an active migration. + require.NoError(t, os.WriteFile(globalPath, []byte(fmt.Sprintf("repos:\n - path: %q\n name: migrating\nmcp:\n allow_embedded: true\n", root)), 0o600)) + oldConfig, oldPath, oldBackend := cfgFile, daemonBackendPath, daemonBackend + t.Cleanup(func() { cfgFile, daemonBackendPath, daemonBackend = oldConfig, oldPath, oldBackend }) + cfgFile, daemonBackendPath, daemonBackend = filepath.Join(base, "repo.yaml"), filepath.Join(base, "store.sqlite"), "sqlite" + require.NoError(t, os.WriteFile(cfgFile, []byte("semantic:\n enabled: false\n"), 0o600)) + seed, err := store_sqlite.Open(daemonBackendPath) + require.NoError(t, err) + t.Cleanup(func() { _ = seed.Close() }) + require.NoError(t, seed.Close()) + db, err := sql.Open("sqlite", daemonBackendPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + _, err = db.Exec("PRAGMA user_version=39") + require.NoError(t, err) + require.NoError(t, db.Close()) + + lock := flock.New(daemon.SpawnLockPath()) + require.NoError(t, os.MkdirAll(filepath.Dir(daemon.SpawnLockPath()), 0o700)) + require.NoError(t, lock.Lock()) + t.Cleanup(func() { require.NoError(t, lock.Close()) }) + reporter := newDaemonStartupReporter(zap.NewNop()) + t.Cleanup(reporter.Stop) + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + type buildResult struct { + state *daemonState + err error + } + built := make(chan buildResult, 1) + go func() { + state, buildErr := buildDaemonState(zap.NewNop(), func(p store_sqlite.MigrationProgress) { + reporter.ObserveMigration(p) + if p.Version == 40 && p.Phase == store_sqlite.MigrationStarted { + close(entered) + <-release + } + }) + built <- buildResult{state, buildErr} + }() + var result buildResult + buildJoined := false + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + if !buildJoined { + select { + case result = <-built: + case <-time.After(10 * time.Second): + t.Error("migration build did not join") + return + } + } + if result.state != nil { + require.NoError(t, result.state.shared.Close()) + } + }) + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("the actual v40 migration did not start") + } + state, ok := daemon.ReadRuntimeState() + require.True(t, ok) + require.Equal(t, daemon.StartupMigrating, state.StartupPhase) + require.Equal(t, os.Getpid(), state.PID) + require.False(t, daemon.IsRunning(), "the real socket must be absent during migration") + + executable, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + child := exec.CommandContext(ctx, executable, "-test.run=^TestMigrationWindowMCPProxyHelper$") + child.Dir = root + child.Env = append(os.Environ(), "GORTEX_TEST_MIGRATION_PROXY=1", "GORTEX_TEST_MIGRATION_WITNESS="+base) + stdin, err := child.StdinPipe() + require.NoError(t, err) + stdout, err := child.StdoutPipe() + require.NoError(t, err) + var stderr bytes.Buffer + child.Stderr = &stderr + require.NoError(t, child.Start()) + exited := make(chan error, 1) + go func() { exited <- child.Wait() }() + reaped := false + t.Cleanup(func() { + _ = stdin.Close() + cancel() + if !reaped { + select { + case <-exited: + case <-time.After(2 * time.Second): + t.Error("owned MCP helper did not reap") + } + } + }) + frames := streamProxyFrames(ctx, stdout) + _, err = fmt.Fprintln(stdin, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"migration-probe","version":"1"}}}`) + require.NoError(t, err) + var first migrationProxyWaitWitness + require.Eventually(t, func() bool { + body, readErr := os.ReadFile(filepath.Join(base, "first.json")) + return readErr == nil && json.Unmarshal(body, &first) == nil + }, 5*time.Second, 5*time.Millisecond) + require.True(t, first.LockContended) + require.Equal(t, os.Getpid(), first.State.PID) + require.Equal(t, daemon.StartupMigrating, first.State.StartupPhase) + // Time the hold only after an independent subprocess has witnessed both + // the actual failed flock and the actual active migration heartbeat. + select { + case frame := <-frames: + t.Fatalf("premature fallback/embedded output during migration: %s (%v)", frame.frame, frame.err) + case childErr := <-exited: + reaped = true + t.Fatalf("MCP exited during migration: %v", childErr) + case <-time.After(3 * migrationProxyTestInactivity): + } + var latest migrationProxyWaitWitness + body, err := os.ReadFile(filepath.Join(base, "latest.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &latest)) + require.Greater(t, latest.Checks, first.Checks, "the real contended wait must continue after its initial inactivity budget") + require.True(t, latest.LockContended) + require.Equal(t, first.State.PID, latest.State.PID) + select { + case frame := <-frames: + t.Fatalf("MCP produced output before migration release: %s (%v)", frame.frame, frame.err) + default: + } + releaseOnce.Do(func() { close(release) }) + select { + case result = <-built: + buildJoined = true + case <-ctx.Done(): + t.Fatal("migration did not complete after release") + } + require.NoError(t, result.err) + srv := daemon.New(os.Getenv("GORTEX_DAEMON_SOCKET"), "test", zap.NewNop()) + srv.MCPDispatcher = newMCPDispatcher(result.state.mcpServer, result.state.multiIndexer, zap.NewNop()) + require.NoError(t, listenConfiguredDaemon(srv, result.state)) + reporter.Serving(daemonBackendPath) + done := make(chan error, 1) + go func() { done <- srv.Serve() }() + t.Cleanup(func() { + require.NoError(t, srv.Shutdown()) + select { + case serveErr := <-done: + require.NoError(t, serveErr) + case <-time.After(5 * time.Second): + t.Error("owned migration-window server did not join") + } + }) + require.NoError(t, lock.Unlock()) + readReply := func() []byte { + t.Helper() + select { + case frame := <-frames: + require.NoError(t, frame.err) + return frame.frame + case <-ctx.Done(): + t.Fatal("MCP reply deadline") + return nil + } + } + var initialized struct { + Result struct{ Instructions string } `json:"result"` + } + require.NoError(t, json.Unmarshal(readReply(), &initialized)) + assert.Contains(t, initialized.Result.Instructions, gortexmcp.ServerInstructionsLoading("migrating", "starting", 0)) + _, err = fmt.Fprintln(stdin, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"MigrationProbe"}}}`) + require.NoError(t, err) + reply := readReply() + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + data := errorData(t, parsed) + assert.Equal(t, "repo_loading", data["error_code"]) + assert.Equal(t, true, data["retryable"]) + assert.Contains(t, data, "phase") + assert.Contains(t, data, "percent") + assert.NotContains(t, string(reply), "gortex track") + assert.NotContains(t, string(reply), "repos reconcile") + require.NoError(t, stdin.Close()) + select { + case childErr := <-exited: + reaped = true + require.NoError(t, childErr, "%s", stderr.String()) + case <-ctx.Done(): + t.Fatal("MCP helper did not exit after closing stdio") + } + assert.NotContains(t, stderr.String(), "embedded") +} From 523311a6488d4cedfe633b3b061c56c6c2f5af67 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 05:36:07 +0100 Subject: [PATCH 15/34] test(startup): migrate remaining scoped MCP fixture registration --- internal/mcp/analyze_scope_test.go | 3 +-- internal/mcp/ensure_fresh_self_heal_test.go | 1 + internal/mcp/overlay_view_request_test.go | 3 +-- internal/mcp/overlay_workspace_stamp_test.go | 3 +-- internal/mcp/path_indexability_test.go | 1 + internal/mcp/scope_resolve_test.go | 12 ++++-------- internal/mcp/view_ref_test.go | 4 +--- internal/mcp/view_request_test.go | 4 +--- internal/mcp/workspace_isolation_test.go | 3 +-- internal/mcp/workspace_root_scope_test.go | 3 +-- 10 files changed, 13 insertions(+), 24 deletions(-) diff --git a/internal/mcp/analyze_scope_test.go b/internal/mcp/analyze_scope_test.go index 79484cc8d..b44060715 100644 --- a/internal/mcp/analyze_scope_test.go +++ b/internal/mcp/analyze_scope_test.go @@ -82,8 +82,7 @@ func newAnalyzeServer(t *testing.T, flagOn bool, repos ...analyzeRepoSpec) (*Ser reg := testRegistry() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) diff --git a/internal/mcp/ensure_fresh_self_heal_test.go b/internal/mcp/ensure_fresh_self_heal_test.go index 827731fd2..8df3c63f5 100644 --- a/internal/mcp/ensure_fresh_self_heal_test.go +++ b/internal/mcp/ensure_fresh_self_heal_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "testing" "github.com/stretchr/testify/assert" diff --git a/internal/mcp/overlay_view_request_test.go b/internal/mcp/overlay_view_request_test.go index e29753dbd..e50d5e490 100644 --- a/internal/mcp/overlay_view_request_test.go +++ b/internal/mcp/overlay_view_request_test.go @@ -86,8 +86,7 @@ func newOverlayRequestFixture(t *testing.T) overlayRequestFixture { g := graph.New() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, testRegistry(), bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) diff --git a/internal/mcp/overlay_workspace_stamp_test.go b/internal/mcp/overlay_workspace_stamp_test.go index 084c86495..083ba59fe 100644 --- a/internal/mcp/overlay_workspace_stamp_test.go +++ b/internal/mcp/overlay_workspace_stamp_test.go @@ -64,8 +64,7 @@ func newOverlayStampFixture(t *testing.T) stampFixture { g := graph.New() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, testRegistry(), bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) diff --git a/internal/mcp/path_indexability_test.go b/internal/mcp/path_indexability_test.go index fd517fdb0..2ab93da4c 100644 --- a/internal/mcp/path_indexability_test.go +++ b/internal/mcp/path_indexability_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "os" "path/filepath" "testing" diff --git a/internal/mcp/scope_resolve_test.go b/internal/mcp/scope_resolve_test.go index 76bde705e..ce8f13c2a 100644 --- a/internal/mcp/scope_resolve_test.go +++ b/internal/mcp/scope_resolve_test.go @@ -79,8 +79,7 @@ func newSharedWorkspaceServer(t *testing.T, flagOn bool) sharedWSOptions { reg := testRegistry() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) @@ -117,8 +116,7 @@ func newSplitProjectWorkspaceServer(t *testing.T, flagOn bool) sharedWSOptions { reg := testRegistry() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) @@ -696,8 +694,7 @@ func newLoneRepoServer(t *testing.T, flagOn bool) (*Server, string) { reg := testRegistry() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) @@ -838,8 +835,7 @@ func newTwoRepoServer(t *testing.T) (*Server, string) { backend := search.NewSymbolSearcherBackend(store) mi := indexer.NewMultiIndexer(store, testRegistry(), backend, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) requireSymbolFTS(t, store) eng := query.NewEngine(store) diff --git a/internal/mcp/view_ref_test.go b/internal/mcp/view_ref_test.go index 12736d83c..027c699af 100644 --- a/internal/mcp/view_ref_test.go +++ b/internal/mcp/view_ref_test.go @@ -168,9 +168,7 @@ func newUnadmittedRefStack(t *testing.T) *refStack { languages.RegisterAll(reg) bm := search.NewNull() mi := indexer.NewMultiIndexer(store, reg, bm, cm, zap.NewNop()) - if _, err := mi.IndexScoped("", ""); err != nil { - t.Fatalf("index the fixture repository: %v", err) - } + registerMCPFixtureRepos(t, mi, cm) graphID := indexer.GraphIDFor(refTestPrefix) seedRefCatalog(t, store, graphID, repo, headTree) diff --git a/internal/mcp/view_request_test.go b/internal/mcp/view_request_test.go index e82421293..5b674ab22 100644 --- a/internal/mcp/view_request_test.go +++ b/internal/mcp/view_request_test.go @@ -154,9 +154,7 @@ func newViewStackWithRepos(t *testing.T, includeOther bool) *viewStack { languages.RegisterAll(reg) bm := search.NewNull() mi := indexer.NewMultiIndexer(store, reg, bm, cm, zap.NewNop()) - if _, err := mi.IndexScoped("", ""); err != nil { - t.Fatalf("index fixture repos: %v", err) - } + registerMCPFixtureRepos(t, mi, cm) graphID := indexer.GraphIDFor("repo") stack := &viewStack{ diff --git a/internal/mcp/workspace_isolation_test.go b/internal/mcp/workspace_isolation_test.go index 6801f1000..13d272a10 100644 --- a/internal/mcp/workspace_isolation_test.go +++ b/internal/mcp/workspace_isolation_test.go @@ -59,8 +59,7 @@ func newIsolationServer(t *testing.T) (srv *Server, repoA, repoB string) { reg := testRegistry() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, reg, bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") // index every configured repo - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) // index every configured repo eng := query.NewEngine(g) eng.SetSearch(bm) diff --git a/internal/mcp/workspace_root_scope_test.go b/internal/mcp/workspace_root_scope_test.go index 386e9df04..4c478f239 100644 --- a/internal/mcp/workspace_root_scope_test.go +++ b/internal/mcp/workspace_root_scope_test.go @@ -67,8 +67,7 @@ func newContainedFixture(t *testing.T) containedFixture { g := graph.New() bm := search.NewNull() mi := indexer.NewMultiIndexer(g, testRegistry(), bm, cm, zap.NewNop()) - _, err = mi.IndexScoped("", "") - require.NoError(t, err) + registerMCPFixtureRepos(t, mi, cm) eng := query.NewEngine(g) eng.SetSearch(bm) From be65c4c201005d21c923497c52f9eb25b45f0d5b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 05:37:22 +0100 Subject: [PATCH 16/34] test(startup): restore the owned migration fixture in MCP helper --- cmd/gortex/migration_window_proxy_test.go | 62 ++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/cmd/gortex/migration_window_proxy_test.go b/cmd/gortex/migration_window_proxy_test.go index 808decf1b..bb2749f40 100644 --- a/cmd/gortex/migration_window_proxy_test.go +++ b/cmd/gortex/migration_window_proxy_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "testing" "time" @@ -34,6 +35,35 @@ type migrationProxyWaitWitness struct { State daemon.RuntimeState `json:"state"` } +type migrationProxyHelperInput struct { + Root string `json:"root"` + ParentPID int `json:"parent_pid"` + SpawnLock string `json:"spawn_lock"` + GlobalPath string `json:"global_config"` +} + +// The helper's TestMain installs a new sandbox before this test runs. Restore +// only these paths derived from its parent's fresh short fixture, not an +// inherited environment dump or any of the user's per-user state. +func migrationProxyOwnedEnv(root string) map[string]string { + home := filepath.Join(root, "home") + return map[string]string{ + "HOME": home, "USERPROFILE": home, + "AppData": filepath.Join(home, "AppData", "Roaming"), + "LocalAppData": filepath.Join(home, "AppData", "Local"), + "XDG_CONFIG_HOME": filepath.Join(root, "config"), + "XDG_DATA_HOME": filepath.Join(root, "data"), + "XDG_CACHE_HOME": filepath.Join(root, "cache"), + "XDG_STATE_HOME": filepath.Join(root, "state"), + "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"), + "GORTEX_DAEMON_SOCKET": filepath.Join(root, "s"), + "GORTEX_DAEMON_PIDFILE": filepath.Join(root, "p"), + "GORTEX_DAEMON_STATEFILE": filepath.Join(root, "state.json"), + "GORTEX_DAEMON_LOGFILE": filepath.Join(root, "daemon.log"), + "GORTEX_DAEMON_SNAPSHOT": filepath.Join(root, "daemon.gob.gz"), + } +} + // This helper runs the real MCP selection and proxy in a fresh process. Only // that process shortens the private inactivity budget. Its liveness seam still // returns the actual socket result; the extra lock/heartbeat reads only record @@ -43,8 +73,22 @@ func TestMigrationWindowMCPProxyHelper(t *testing.T) { t.Skip("invoked only by TestMCPWaitsThroughMigrationWindow") } spawnLockInactivityTimeout = migrationProxyTestInactivity - witnessDir := os.Getenv("GORTEX_TEST_MIGRATION_WITNESS") - require.NotEmpty(t, witnessDir) + inputPath := os.Getenv("GORTEX_TEST_MIGRATION_INPUT") + require.True(t, filepath.IsAbs(inputPath)) + body, err := os.ReadFile(inputPath) + require.NoError(t, err) + var input migrationProxyHelperInput + require.NoError(t, json.Unmarshal(body, &input)) + require.Equal(t, os.Getppid(), input.ParentPID) + require.True(t, filepath.IsAbs(input.Root)) + require.True(t, strings.HasPrefix(filepath.Base(input.Root), "gx")) + require.Equal(t, filepath.Join(input.Root, "migration-helper-input.json"), inputPath) + for name, value := range migrationProxyOwnedEnv(input.Root) { + t.Setenv(name, value) + } + require.Equal(t, input.SpawnLock, daemon.SpawnLockPath()) + require.Equal(t, input.GlobalPath, config.DefaultGlobalConfigPath()) + witnessDir := input.Root checks := 0 isDaemonRunning = func() bool { running := daemon.IsRunning() @@ -88,9 +132,9 @@ func TestMigrationWindowMCPProxyHelper(t *testing.T) { func TestMCPWaitsThroughMigrationWindow(t *testing.T) { base := testenv.ShortTempDir(t) testenv.SandboxAt(t, filepath.Join(base, "home")) - t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(base, "s")) - t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(base, "p")) - t.Setenv("GORTEX_DAEMON_STATEFILE", filepath.Join(base, "state.json")) + for name, value := range migrationProxyOwnedEnv(base) { + t.Setenv(name, value) + } t.Setenv("GORTEX_AUTOSTART", "1") t.Setenv("GORTEX_TELEMETRY", "0") t.Setenv("GORTEX_TOOLS", "") @@ -173,7 +217,13 @@ func TestMCPWaitsThroughMigrationWindow(t *testing.T) { defer cancel() child := exec.CommandContext(ctx, executable, "-test.run=^TestMigrationWindowMCPProxyHelper$") child.Dir = root - child.Env = append(os.Environ(), "GORTEX_TEST_MIGRATION_PROXY=1", "GORTEX_TEST_MIGRATION_WITNESS="+base) + inputPath := filepath.Join(base, "migration-helper-input.json") + input, err := json.Marshal(migrationProxyHelperInput{ + Root: base, ParentPID: os.Getpid(), SpawnLock: daemon.SpawnLockPath(), GlobalPath: globalPath, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(inputPath, input, 0o600)) + child.Env = append(os.Environ(), "GORTEX_TEST_MIGRATION_PROXY=1", "GORTEX_TEST_MIGRATION_INPUT="+inputPath) stdin, err := child.StdinPipe() require.NoError(t, err) stdout, err := child.StdoutPipe() From a8126393c721533e9af0b7fe661c85625bfc820e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:06:16 +0100 Subject: [PATCH 17/34] test(startup): isolate migration proxy from path alias coverage --- cmd/gortex/migration_window_proxy_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/gortex/migration_window_proxy_test.go b/cmd/gortex/migration_window_proxy_test.go index bb2749f40..3277f50a1 100644 --- a/cmd/gortex/migration_window_proxy_test.go +++ b/cmd/gortex/migration_window_proxy_test.go @@ -131,6 +131,11 @@ func TestMigrationWindowMCPProxyHelper(t *testing.T) { // separate; this fixture deliberately replays the idempotent v40 step. func TestMCPWaitsThroughMigrationWindow(t *testing.T) { base := testenv.ShortTempDir(t) + // Keep this migration regression's config and actual child CWD under the + // same physical spelling. Alias admission is a separate unresolved case; + // /tmp versus /private/tmp must not conceal the migration wait contract. + base, err := filepath.EvalSymlinks(base) + require.NoError(t, err) testenv.SandboxAt(t, filepath.Join(base, "home")) for name, value := range migrationProxyOwnedEnv(base) { t.Setenv(name, value) @@ -152,8 +157,9 @@ func TestMCPWaitsThroughMigrationWindow(t *testing.T) { require.NoError(t, os.WriteFile(cfgFile, []byte("semantic:\n enabled: false\n"), 0o600)) seed, err := store_sqlite.Open(daemonBackendPath) require.NoError(t, err) - t.Cleanup(func() { _ = seed.Close() }) - require.NoError(t, seed.Close()) + closeSeed := sync.OnceValue(seed.Close) + t.Cleanup(func() { _ = closeSeed() }) + require.NoError(t, closeSeed()) db, err := sql.Open("sqlite", daemonBackendPath) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) From cfc6738a1ef1991b68e6b5507626c74e2e87e2d0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:22:33 +0100 Subject: [PATCH 18/34] fix(indexer): publish error-aware configured identities outside lock --- internal/indexer/repo_load_identity_test.go | 132 +++++++++++++++++++ internal/indexer/repo_load_state.go | 135 +++++++++++++++----- internal/indexer/worktree.go | 31 ++++- 3 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 internal/indexer/repo_load_identity_test.go diff --git a/internal/indexer/repo_load_identity_test.go b/internal/indexer/repo_load_identity_test.go new file mode 100644 index 000000000..037534a45 --- /dev/null +++ b/internal/indexer/repo_load_identity_test.go @@ -0,0 +1,132 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/pathkey" +) + +func TestRepoIdentityColdAliasPublication(t *testing.T) { + base := t.TempDir() + physical := filepath.Join(base, "physical-repository") + alias := filepath.Join(base, "different-configured-name") + require.NoError(t, os.MkdirAll(filepath.Join(physical, ".git"), 0755)) + require.NoError(t, os.Symlink(physical, alias)) + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: alias}} + mi.DeclareRepos(entries) + require.True(t, mi.RepoIdentityPending()) + _, known := mi.PendingRepoForPath(physical) + require.False(t, known, "an unresolved declaration cannot invent ownership") + mi.ExpectRepos(entries) + require.False(t, mi.RepoIdentityPending()) + rec, known := mi.PendingRepoForPath(physical) + require.True(t, known) + require.Equal(t, alias, rec.Root) +} + +func TestRepoIdentityDarwinTmpAliasPublication(t *testing.T) { + canonical, err := filepath.EvalSymlinks("/tmp") + require.NoError(t, err) + if canonical != "/private/tmp" { + t.Skip("host does not expose the /tmp -> /private/tmp alias") + } + root, err := os.MkdirTemp("/tmp", "repo-load-alias-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: root}} + mi.DeclareRepos(entries) + physical := filepath.Join(canonical, filepath.Base(root)) + require.True(t, mi.RepoIdentityPending()) + _, known := mi.PendingRepoForPath(physical) + require.False(t, known) + mi.ExpectRepos(entries) + rec, known := mi.PendingRepoForPath(physical) + require.True(t, known) + require.Equal(t, root, rec.Root) +} + +func TestRepoIdentityPublicationDoesNotOverrideRegistrationOrRedeclaration(t *testing.T) { + for _, transition := range []string{"registration", "redeclaration", "running"} { + t.Run(transition, func(t *testing.T) { + root := t.TempDir() + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: root}} + mi.DeclareRepos(entries) + before := mi.RepoLoads()[0] + resolved, err := resolveRepoLoadIdentity(before) + require.NoError(t, err) + switch transition { + case "registration": + mi.markRepoRegistered("ready", root) + case "redeclaration": + mi.DeclareRepos(nil) + mi.DeclareRepos(entries) + case "running": + mi.StartRepoLoad(root) + } + mi.publishRepoLoadIdentity(before, resolved) + after, ok := mi.RepoLoadFor(root) + require.True(t, ok) + switch transition { + case "registration": + require.Equal(t, RepoLoadReady, after.State) + require.False(t, mi.RepoIdentityPending()) + case "redeclaration": + require.True(t, mi.RepoIdentityPending(), "stale owner result must not resolve a replacement declaration") + case "running": + require.Equal(t, RepoLoadRunning, after.State) + require.False(t, mi.RepoIdentityPending()) + } + }) + } +} + +func TestRepoIdentityUncertainFilesystemRetainsNegativeFence(t *testing.T) { + root := t.TempDir() + // A directory where commondir should be a readable file produces a real + // non-ENOENT I/O failure regardless of the test user's permission privileges. + admin := filepath.Join(t.TempDir(), "admin") + require.NoError(t, os.MkdirAll(filepath.Join(admin, "commondir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: "+admin), 0644)) + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: root}} + mi.ExpectRepos(entries) + require.True(t, mi.RepoIdentityPending()) + rec, _ := mi.RepoLoadFor(root) + require.Equal(t, RepoLoadQueued, rec.State) + _, err := resolveRepoLoadIdentity(rec) + require.Error(t, err) + require.False(t, os.IsNotExist(err)) + require.NoError(t, os.Remove(filepath.Join(root, ".git"))) + mi.ExpectRepos(entries) + require.False(t, mi.RepoIdentityPending()) +} + +func TestRepoIdentitySeparateGitDirAndLinkedBoundary(t *testing.T) { + primary, linked := t.TempDir(), t.TempDir() + common := filepath.Join(t.TempDir(), "external-git") + admin := filepath.Join(common, "worktrees", "linked") + require.NoError(t, os.MkdirAll(admin, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(primary, ".git"), []byte("gitdir: "+common), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+admin), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(admin, "commondir"), []byte("../.."), 0644)) + info := ResolveWorktree(primary) + require.False(t, info.IsWorktree) + require.Equal(t, primary, info.MainRepoPath) + require.True(t, pathkey.EqualPaths(common, info.GitCommonDir)) + mi := newRepoLoadTestMulti(t) + mi.ExpectRepos([]config.RepoEntry{{Path: primary}}) + rec, known := mi.PendingRepoForPath(linked) + require.True(t, known) + require.Equal(t, primary, rec.Root) + nested := filepath.Join(linked, "nested") + require.NoError(t, os.MkdirAll(filepath.Join(nested, ".git"), 0755)) + _, known = mi.PendingRepoForPath(nested) + require.False(t, known, "the nearest unrelated git identity is a boundary") +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index ecc9ef356..973de9994 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -1,6 +1,7 @@ package indexer import ( + "errors" "os" "path/filepath" "slices" @@ -81,6 +82,7 @@ type RepoLoad struct { // identityPending distinguishes a lexical boot declaration from the // filesystem identity warmup fills in. It grants no graph admission. identityPending bool + identityToken *byte } // Label names the repository in user-facing text: its prefix, or its root @@ -108,13 +110,17 @@ type repoLoadSnapshot struct { index map[string]int // loading counts records in a non-terminal state, so the worktree arm // can skip its filesystem walk once warmup has settled. - loading int + loading int + identityPending bool } func newRepoLoadSnapshot(records []RepoLoad) *repoLoadSnapshot { snap := &repoLoadSnapshot{records: records, index: make(map[string]int, 2*len(records))} for i, rec := range records { snap.index[rec.key] = i + if rec.identityPending { + snap.identityPending = true + } if rec.canonicalKey != "" { snap.index[rec.canonicalKey] = i } @@ -217,54 +223,103 @@ func (mi *MultiIndexer) expectRepos(entries []config.RepoEntry, inspect bool) { wanted := make(map[int]bool, len(entries)) addedKeys := make(map[string]bool) var added []RepoLoad - identityChanged := false for _, entry := range entries { root := absRepoRoot(entry.Path) - if root == "" || addedKeys[pathkey.IdentityKey(root)] { + key := pathkey.IdentityKey(root) + if root == "" || addedKeys[key] { continue } if i, ok := current.find(root); ok { wanted[i] = true - if inspect && records[i].identityPending { - rec := &records[i] - resolved := newRepoLoad(root, rec.Prefix, rec.State, rec.Since) - rec.canonicalKey, rec.commonDir = resolved.canonicalKey, resolved.commonDir - rec.identityPending = false - if rec.State == RepoLoadQueued { - if info, err := os.Stat(root); err != nil || !info.IsDir() { - rec.State, rec.Reason, rec.Since = RepoLoadMissing, "the configured path does not exist", now - } - } - identityChanged = true - } - continue - } - if !inspect { - rec := RepoLoad{Root: root, Prefix: config.ResolvePrefix(entry), State: RepoLoadQueued, - Since: now, key: pathkey.IdentityKey(root), commonDir: filepath.Join(root, ".git"), identityPending: true} - added = append(added, rec) - addedKeys[rec.key] = true continue } - state := RepoLoadQueued - reason := "" - if info, err := os.Stat(root); err != nil || !info.IsDir() { - state, reason = RepoLoadMissing, "the configured path does not exist" - } - rec := newRepoLoad(root, config.ResolvePrefix(entry), state, now) - rec.Reason = reason - added = append(added, rec) - addedKeys[rec.key] = true + added = append(added, RepoLoad{Root: root, Prefix: config.ResolvePrefix(entry), State: RepoLoadQueued, + Since: now, key: key, commonDir: filepath.Join(root, ".git"), identityPending: true, identityToken: new(byte)}) + addedKeys[key] = true } next := make([]RepoLoad, 0, len(records)+len(added)) for i, rec := range records { - if wanted[i] || rec.State == RepoLoadReady { + if wanted[i] || rec.State.Ready() { next = append(next, rec) } } next = append(next, added...) - return next, identityChanged || len(added) > 0 || len(next) != len(records) + return next, len(added) > 0 || len(next) != len(records) }) + if !inspect { + return + } + // Only the existing warmup owner resolves identities. No filesystem access + // holds the publication lock; each completed root becomes visible before the + // next root is inspected, even if that next mount never answers. + for _, rec := range mi.repoLoads.load().records { + if !rec.identityPending { + continue + } + resolved, err := resolveRepoLoadIdentity(rec) + if err != nil { + continue + } // EACCES/EIO is uncertainty, not an absent alias. + mi.publishRepoLoadIdentity(rec, resolved) + } +} + +func resolveRepoLoadIdentity(rec RepoLoad) (RepoLoad, error) { + canonical, err := filepath.EvalSymlinks(rec.Root) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return rec, err + } + rec.State, rec.Reason = RepoLoadMissing, "the configured path does not exist" + } else { + st, statErr := os.Stat(canonical) + if statErr != nil { + return rec, statErr + } + if !st.IsDir() { + rec.State, rec.Reason = RepoLoadMissing, "the configured path is not a directory" + } else { + rec.canonicalKey = pathkey.IdentityKey(pathkey.NormalizeVolume(canonical)) + info, resolveErr := resolveWorktreeChecked(canonical) + if resolveErr != nil { + return rec, resolveErr + } + rec.commonDir = "" + if info.GitCommonDir != "" { + common, commonErr := filepath.EvalSymlinks(info.GitCommonDir) + if commonErr != nil { + return rec, commonErr + } + rec.commonDir = pathkey.NormalizeVolume(common) + } + } + } + rec.identityPending = false + return rec, nil +} + +func (mi *MultiIndexer) publishRepoLoadIdentity(before, resolved RepoLoad) { + mi.repoLoads.update(func(records []RepoLoad) ([]RepoLoad, bool) { + i, ok := newRepoLoadSnapshot(records).find(before.Root) + if !ok || !records[i].identityPending || records[i].identityToken != before.identityToken { + return records, false + } + rec := &records[i] + rec.canonicalKey, rec.commonDir, rec.identityPending = resolved.canonicalKey, resolved.commonDir, false + if rec.State == RepoLoadQueued && resolved.State == RepoLoadMissing { + rec.State, rec.Reason, rec.Since = resolved.State, resolved.Reason, time.Now() + } + return records, true + }) +} + +// RepoIdentityPending is a cheap negative-admission fence, never proof that a +// particular path belongs to a configured repository. Authoritative positive +// scope/checkout/router answers must take precedence. It remains true after an +// uncertain filesystem outcome until warmup resolves identity or registration +// itself proves ownership. +func (mi *MultiIndexer) RepoIdentityPending() bool { + return mi != nil && mi.repoLoads.load().identityPending } // StartRepoLoad marks a queued repository as being worked on by warmup. @@ -385,6 +440,7 @@ func (mi *MultiIndexer) markRepoRegistered(prefix, root string) { return } now := time.Now() + registered := newRepoLoad(root, prefix, RepoLoadReady, now) var announced RepoLoad var listeners []func(RepoLoad) mi.repoLoads.update(func(records []RepoLoad) ([]RepoLoad, bool) { @@ -392,13 +448,14 @@ func (mi *MultiIndexer) markRepoRegistered(prefix, root string) { if i, ok := newRepoLoadSnapshot(records).find(root); ok { records[i].State, records[i].Reason, records[i].Since = RepoLoadReady, "", now records[i].commonDir = "" + records[i].identityPending = false if prefix != "" { records[i].Prefix = prefix } announced = records[i] return records, true } - announced = newRepoLoad(root, prefix, RepoLoadReady, now) + announced = registered return append(records, announced), true }) if len(listeners) == 0 { @@ -480,6 +537,14 @@ func (mi *MultiIndexer) PendingRepoForPath(path string) (RepoLoad, bool) { if rec, ok := snap.enclosing(abs); ok { return rec, !rec.State.Ready() } + // Until the owner resolves aliases, an unmatched request cannot prove + // absence. Do not walk its filesystem merely to manufacture a negative. + if snap.identityPending { + if rec, ok := snap.contained(abs); ok { + return rec, true + } + return RepoLoad{}, false + } if canonical := pathkey.CanonicalPath(abs); canonical != abs { if rec, ok := snap.enclosing(canonical); ok { return rec, !rec.State.Ready() @@ -545,7 +610,7 @@ func enclosingWorktreeCommonDir(path string) string { for dir := filepath.Clean(path); ; { if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { info := ResolveWorktree(dir) - if !info.IsWorktree || info.GitCommonDir == "" { + if info.GitCommonDir == "" { return "" } // Git writes a worktree's gitdir with symlinks resolved while a diff --git a/internal/indexer/worktree.go b/internal/indexer/worktree.go index cef1f9641..2939e79e8 100644 --- a/internal/indexer/worktree.go +++ b/internal/indexer/worktree.go @@ -2,6 +2,7 @@ package indexer import ( "context" + "errors" "fmt" "hash/fnv" "os" @@ -43,6 +44,14 @@ type WorktreeInfo struct { // repository have multiple checkouts, each assigned to its own // workspace and indexed from its own branch / working tree. func ResolveWorktree(path string) WorktreeInfo { + info, _ := resolveWorktreeChecked(path) + return info +} + +// resolveWorktreeChecked keeps I/O uncertainty distinct from a non-git root. +// Startup identity publication must not treat a best-effort fallback as proof +// that no configured alias can own a request. +func resolveWorktreeChecked(path string) (WorktreeInfo, error) { abs, err := filepath.Abs(path) if err != nil { abs = path @@ -52,23 +61,26 @@ func ResolveWorktree(path string) WorktreeInfo { gitPath := filepath.Join(abs, ".git") st, err := os.Stat(gitPath) if err != nil { - return info // not a git repository + if errors.Is(err, os.ErrNotExist) { + return info, nil + } + return info, err } if st.IsDir() { info.GitCommonDir = gitPath - return info // the main checkout + return info, nil // the main checkout } // `.git` is a file: a linked worktree or a submodule. Read the // gitdir indirection. content, err := os.ReadFile(gitPath) if err != nil { - return info + return info, err } line := strings.TrimSpace(string(content)) wtGitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) if wtGitDir == "" || wtGitDir == line { - return info // malformed .git file + return info, fmt.Errorf("malformed gitdir in %s", gitPath) } if !filepath.IsAbs(wtGitDir) { wtGitDir = filepath.Join(abs, wtGitDir) @@ -78,9 +90,16 @@ func ResolveWorktree(path string) WorktreeInfo { // submodule's does not. Its absence means "not a worktree". commonRaw, err := os.ReadFile(filepath.Join(wtGitDir, "commondir")) if err != nil { - return info + if errors.Is(err, os.ErrNotExist) { + info.GitCommonDir = filepath.Clean(wtGitDir) + return info, nil // separate gitdir or submodule: still its own primary + } + return info, err } common := strings.TrimSpace(string(commonRaw)) + if common == "" { + return info, fmt.Errorf("empty commondir in %s", wtGitDir) + } if !filepath.IsAbs(common) { common = filepath.Join(wtGitDir, common) } @@ -92,7 +111,7 @@ func ResolveWorktree(path string) WorktreeInfo { if filepath.Base(common) == ".git" { info.MainRepoPath = filepath.Dir(common) } - return info + return info, nil } // WorktreeInstanceName decides the repo prefix that absPath should be From c418576f0ef2bcf9b468fb277af0b31708e502b2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:26:45 +0100 Subject: [PATCH 19/34] fix(indexer): retain identity uncertainty across registration races --- internal/indexer/repo_load_identity_test.go | 61 +++++++++++++++++ internal/indexer/repo_load_state.go | 73 ++++++++++++++++----- internal/indexer/worktree.go | 8 +++ 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/internal/indexer/repo_load_identity_test.go b/internal/indexer/repo_load_identity_test.go index 037534a45..fcb28fe1e 100644 --- a/internal/indexer/repo_load_identity_test.go +++ b/internal/indexer/repo_load_identity_test.go @@ -3,6 +3,7 @@ package indexer import ( "os" "path/filepath" + "syscall" "testing" "github.com/stretchr/testify/require" @@ -130,3 +131,63 @@ func TestRepoIdentitySeparateGitDirAndLinkedBoundary(t *testing.T) { _, known = mi.PendingRepoForPath(nested) require.False(t, known, "the nearest unrelated git identity is a boundary") } + +func TestRepoIdentityRegistrationPublishesCanonicalBinding(t *testing.T) { + physical := t.TempDir() + alias := filepath.Join(t.TempDir(), "different-name") + require.NoError(t, os.Symlink(physical, alias)) + mi := newRepoLoadTestMulti(t) + mi.DeclareRepos([]config.RepoEntry{{Path: alias}}) + before := mi.RepoLoads()[0] + resolved, err := resolveRepoLoadIdentity(before) + require.NoError(t, err) + mi.markRepoRegistered("registered", alias) + mi.publishRepoLoadIdentity(before, resolved) + require.False(t, mi.RepoIdentityPending()) + rec, found := mi.RepoLoadFor(physical) + require.True(t, found, "registration must publish its own proven canonical key even when the warmup publisher loses the race") + require.Equal(t, RepoLoadReady, rec.State) + require.Equal(t, "registered", rec.Prefix) +} + +func TestRepoIdentityRejectsInvalidSeparateGitDir(t *testing.T) { + for _, kind := range []string{"missing", "file"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "not-a-git-directory") + if kind == "file" { + require.NoError(t, os.WriteFile(target, []byte("not a directory"), 0644)) + } + require.NoError(t, os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: "+target), 0644)) + info := ResolveWorktree(root) + require.Empty(t, info.GitCommonDir) + require.False(t, info.IsWorktree) + require.Equal(t, root, info.MainRepoPath) + mi := newRepoLoadTestMulti(t) + mi.ExpectRepos([]config.RepoEntry{{Path: root}}) + require.True(t, mi.RepoIdentityPending()) + require.Empty(t, enclosingWorktreeCommonDir(root)) + }) + } +} + +func TestRepoIdentityPermissionAndDeviceErrorsAreNotNegativeEvidence(t *testing.T) { + for _, cause := range []error{syscall.EACCES, syscall.EIO} { + t.Run(cause.Error(), func(t *testing.T) { + mi := newRepoLoadTestMulti(t) + root := t.TempDir() + mi.DeclareRepos([]config.RepoEntry{{Path: root}}) + before := mi.RepoLoads()[0] + failure := &os.PathError{Op: "lstat", Path: root, Err: cause} + after, err := resolveRepoLoadIdentityWith(before, func(path string) (string, error) { + require.Equal(t, root, path) + return root, failure // even an absolute fallback is not proof on error + }) + require.ErrorIs(t, err, cause) + require.True(t, after.identityPending) + require.Equal(t, RepoLoadQueued, after.State) + require.Empty(t, after.canonicalKey) + require.True(t, mi.RepoIdentityPending()) + }) + } +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index 973de9994..bae3591bb 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -28,6 +28,9 @@ import ( type RepoLoadState string const ( + // RepoLoadIdentityPending is an unmatched request while configured root + // identities are unresolved. It names no repository and grants no scope. + RepoLoadIdentityPending RepoLoadState = "identity_pending" // RepoLoadQueued is a configured repository warmup has not started yet. RepoLoadQueued RepoLoadState = "queued" // RepoLoadRunning is a configured repository warmup is indexing or @@ -44,7 +47,7 @@ const ( // Loading reports a state that has not reached a terminal answer yet. func (s RepoLoadState) Loading() bool { - return s == RepoLoadQueued || s == RepoLoadRunning + return s == RepoLoadQueued || s == RepoLoadRunning || s == RepoLoadIdentityPending } // Ready reports a registered repository. @@ -166,18 +169,17 @@ func (t *repoLoadTable) update(mutate func(records []RepoLoad) ([]RepoLoad, bool // canonical spelling and git common directory. Called only off the hot path: // when a start announces its repositories and when one registers. func newRepoLoad(root, prefix string, state RepoLoadState, now time.Time) RepoLoad { - rec := RepoLoad{Root: root, Prefix: prefix, State: state, Since: now, key: pathkey.IdentityKey(root)} - if canonical := pathkey.CanonicalExistingRoot(root); canonical != "" { - if key := pathkey.IdentityKey(canonical); key != rec.key { - rec.canonicalKey = key - } - } - if state.Loading() { - if commonDir := ResolveWorktree(root).GitCommonDir; commonDir != "" { - rec.commonDir = pathkey.CanonicalExistingRoot(commonDir) - } + rec := RepoLoad{Root: root, Prefix: prefix, State: state, Since: now, + key: pathkey.IdentityKey(root), identityPending: true, identityToken: new(byte)} + resolved, err := resolveRepoLoadIdentity(rec) + if err != nil { + rec.Reason = "identity resolution pending: " + err.Error() + return rec } - return rec + // Registration is authoritative about ready state even if its directory + // disappeared since indexing. Identity absence is a separate observation. + resolved.State = state + return resolved } // absRepoRoot spells a configured path the way registration does. @@ -258,14 +260,35 @@ func (mi *MultiIndexer) expectRepos(entries []config.RepoEntry, inspect bool) { } resolved, err := resolveRepoLoadIdentity(rec) if err != nil { + // Retain the owner's diagnostic without turning uncertainty into a + // terminal absent-alias result. No request performs this operation again. + mi.repoLoads.update(func(records []RepoLoad) ([]RepoLoad, bool) { + i, ok := newRepoLoadSnapshot(records).find(rec.Root) + if !ok || !records[i].identityPending || records[i].identityToken != rec.identityToken { + return records, false + } + reason := "identity resolution pending: " + err.Error() + if records[i].Reason == reason { + return records, false + } + records[i].Reason = reason + return records, true + }) continue - } // EACCES/EIO is uncertainty, not an absent alias. + } mi.publishRepoLoadIdentity(rec, resolved) } } func resolveRepoLoadIdentity(rec RepoLoad) (RepoLoad, error) { - canonical, err := filepath.EvalSymlinks(rec.Root) + return resolveRepoLoadIdentityWith(rec, filepath.EvalSymlinks) +} + +// The resolver argument lets source-level I/O failures be exercised without +// depending on host privileges or an unavailable device. Production uses only +// the existing warmup owner's synchronous filesystem resolver. +func resolveRepoLoadIdentityWith(rec RepoLoad, canonicalize func(string) (string, error)) (RepoLoad, error) { + canonical, err := canonicalize(rec.Root) if err != nil { if !errors.Is(err, os.ErrNotExist) { return rec, err @@ -306,6 +329,9 @@ func (mi *MultiIndexer) publishRepoLoadIdentity(before, resolved RepoLoad) { } rec := &records[i] rec.canonicalKey, rec.commonDir, rec.identityPending = resolved.canonicalKey, resolved.commonDir, false + if rec.State.Loading() { + rec.Reason = "" + } if rec.State == RepoLoadQueued && resolved.State == RepoLoadMissing { rec.State, rec.Reason, rec.Since = resolved.State, resolved.Reason, time.Now() } @@ -447,8 +473,14 @@ func (mi *MultiIndexer) markRepoRegistered(prefix, root string) { listeners = slices.Clone(mi.repoLoads.listeners) if i, ok := newRepoLoadSnapshot(records).find(root); ok { records[i].State, records[i].Reason, records[i].Since = RepoLoadReady, "", now + if registered.canonicalKey != "" { + records[i].canonicalKey = registered.canonicalKey + } records[i].commonDir = "" - records[i].identityPending = false + records[i].identityPending = registered.identityPending + if registered.identityPending { + records[i].Reason = registered.Reason + } if prefix != "" { records[i].Prefix = prefix } @@ -608,8 +640,15 @@ func (s *repoLoadSnapshot) contained(path string) (RepoLoad, bool) { // path decides: a main checkout or a submodule is not a linked worktree. func enclosingWorktreeCommonDir(path string) string { for dir := filepath.Clean(path); ; { - if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { - info := ResolveWorktree(dir) + _, err := os.Lstat(filepath.Join(dir, ".git")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "" + } + if err == nil { + info, resolveErr := resolveWorktreeChecked(dir) + if resolveErr != nil { + return "" + } if info.GitCommonDir == "" { return "" } diff --git a/internal/indexer/worktree.go b/internal/indexer/worktree.go index 2939e79e8..596f204d7 100644 --- a/internal/indexer/worktree.go +++ b/internal/indexer/worktree.go @@ -86,6 +86,14 @@ func resolveWorktreeChecked(path string) (WorktreeInfo, error) { wtGitDir = filepath.Join(abs, wtGitDir) } + gitInfo, err := os.Stat(wtGitDir) + if err != nil { + return info, err + } + if !gitInfo.IsDir() { + return info, fmt.Errorf("gitdir %s is not a directory", wtGitDir) + } + // Only a worktree's gitdir carries a `commondir` file; a // submodule's does not. Its absence means "not a worktree". commonRaw, err := os.ReadFile(filepath.Join(wtGitDir, "commondir")) From d95fddd22268ae660e5a88f1adfe99ea93abd900 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:28:19 +0100 Subject: [PATCH 20/34] fix(startup): defer negative admission until configured identities resolve --- cmd/gortex/cli_daemon.go | 2 +- cmd/gortex/cli_repo_loading.go | 14 +- cmd/gortex/daemon_controller_view.go | 6 + cmd/gortex/daemon_mcp.go | 18 ++- cmd/gortex/daemon_mcp_repo_loading.go | 10 ++ .../daemon_repo_identity_pending_test.go | 152 ++++++++++++++++++ internal/daemon/proto.go | 5 + internal/hooks/pretooluse.go | 6 +- internal/hooks/repo_loading_test.go | 28 ++++ internal/indexer/repo_load_state.go | 2 +- internal/mcp/checkout_binding.go | 5 +- internal/mcp/repo_identity_pending_test.go | 57 +++++++ internal/mcp/repo_loading.go | 9 ++ internal/mcp/server.go | 5 +- 14 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 cmd/gortex/daemon_repo_identity_pending_test.go create mode 100644 internal/mcp/repo_identity_pending_test.go diff --git a/cmd/gortex/cli_daemon.go b/cmd/gortex/cli_daemon.go index 018d601b7..62161b761 100644 --- a/cmd/gortex/cli_daemon.go +++ b/cmd/gortex/cli_daemon.go @@ -140,7 +140,7 @@ func extractToolResult(resp []byte) (json.RawMessage, error) { if repo == "" { repo = data.RepoPath } - return nil, &repoLoadingError{repo: repo, phase: data.Phase, percent: data.Percent} + return nil, &repoLoadingError{repo: repo, phase: data.Phase, percent: data.Percent, identityPending: data.Reason == "identity_pending"} case string(gortexmcp.ErrCodeRepoUnavailable): return nil, fmt.Errorf("the gortex daemon could not load %s after its restart (%s). %s", data.RepoPath, data.Reason, data.Suggestion) diff --git a/cmd/gortex/cli_repo_loading.go b/cmd/gortex/cli_repo_loading.go index d2b24a958..e96566743 100644 --- a/cmd/gortex/cli_repo_loading.go +++ b/cmd/gortex/cli_repo_loading.go @@ -38,9 +38,10 @@ func init() { // It carries exit code 75, so a script or hook that runs the CLI can retry // later without parsing the message. type repoLoadingError struct { - repo string - phase string - percent int + identityPending bool + repo string + phase string + percent int // waited is how long the CLI already waited before giving up for now. waited time.Duration } @@ -50,6 +51,13 @@ func (e *repoLoadingError) Error() string { if e.waited > 0 { msg += fmt.Sprintf("; still loading after waiting %s", e.waited.Round(time.Second)) } + if e.identityPending { + msg = "the gortex daemon is still resolving configured repository identities (repo_loading, identity_pending)" + if e.waited > 0 { + msg += fmt.Sprintf("; still loading after waiting %s", e.waited.Round(time.Second)) + } + return msg + " — graph admission for this path is not yet established; retry shortly (or pass --loading-wait to wait for it)" + } return msg + " — the repository is already tracked; retry shortly (or pass --loading-wait to wait for it)" } diff --git a/cmd/gortex/daemon_controller_view.go b/cmd/gortex/daemon_controller_view.go index 5d38ba073..11ff5f55d 100644 --- a/cmd/gortex/daemon_controller_view.go +++ b/cmd/gortex/daemon_controller_view.go @@ -331,6 +331,9 @@ func (c *realController) FileCoverage(ctx context.Context, p daemon.FileCoverage // native tool through instead of denying the only read path left. if repo, loading := c.loadingRepoForPath(abs); loading { out.RepoState, out.Repo = daemon.RepoStateLoading, repo + } else if !out.Tracked && c.multiIndexer != nil && c.multiIndexer.RepoIdentityPending() { + out.RepoState = daemon.RepoStateIdentityPending + return out, nil // neither a negative coverage answer nor graph admission } if !view.servable { return out, nil @@ -429,6 +432,9 @@ func (c *realController) DirCoverage(ctx context.Context, p daemon.DirCoveragePa // See FileCoverage: a loading repository's scope must not deny Grep. if repo, loading := c.loadingRepoForPath(abs); loading { out.RepoState, out.Repo = daemon.RepoStateLoading, repo + } else if !out.Tracked && c.multiIndexer != nil && c.multiIndexer.RepoIdentityPending() { + out.RepoState = daemon.RepoStateIdentityPending + return out, nil // neither a negative coverage answer nor graph admission } if !view.servable { return out, nil diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index 7e5c20e90..3ea1a83fe 100644 --- a/cmd/gortex/daemon_mcp.go +++ b/cmd/gortex/daemon_mcp.go @@ -499,14 +499,18 @@ func (d *mcpDispatcher) cwdReachableChecked(ctx context.Context, cwd string) (bo return err == nil && reachable, err } } - rtr := d.router.Load() - if rtr == nil { - return false, nil + if rtr := d.router.Load(); rtr != nil { + lookup := rtr.LookupForCwd(cwd, "") + switch lookup.Source { + case "scope-override", "config-yaml", "roster": + return true, nil + } } - lookup := rtr.LookupForCwd(cwd, "") - switch lookup.Source { - case "scope-override", "config-yaml", "roster": - return true, nil + if err := ctx.Err(); err != nil { + return false, err + } + if d.multiIndexer != nil && d.multiIndexer.RepoIdentityPending() { + return false, &repoPendingAdmission{load: indexer.RepoLoad{State: indexer.RepoLoadIdentityPending}} } return false, nil } diff --git a/cmd/gortex/daemon_mcp_repo_loading.go b/cmd/gortex/daemon_mcp_repo_loading.go index 8bd3cc143..ce27228c3 100644 --- a/cmd/gortex/daemon_mcp_repo_loading.go +++ b/cmd/gortex/daemon_mcp_repo_loading.go @@ -104,6 +104,13 @@ func peekFrameToolCall(frame []byte) (string, map[string]any) { // repoLoadingError is the refusal for a graph-backed frame from a loading // session. Retryable, with progress, and it never suggests tracking. func (d *mcpDispatcher) repoLoadingError(sess *daemon.Session, inbound []byte, rec indexer.RepoLoad) []byte { + if rec.State == indexer.RepoLoadIdentityPending { + return jsonRPCErrorFrame(inbound, "configured repository identity resolution is pending", map[string]any{ + "error_code": string(gortexmcp.ErrCodeRepoLoading), "retryable": true, "path": sess.CWD, + "state": "identity_pending", "reason": "identity_pending", "phase": "identity_resolution", "percent": 0, + "suggestion": gortexmcp.ServerInstructionsIdentityPending(), + }) + } phase, percent := "starting", 0 if d.srv != nil { phase, percent = d.srv.WarmupProgress() @@ -142,6 +149,9 @@ func repoUnavailableError(sess *daemon.Session, inbound []byte, rec indexer.Repo // unbound session must see only its own, so the answer is built here rather // than filtered out of that snapshot. func (d *mcpDispatcher) loadingIndexHealth(inbound []byte, rec indexer.RepoLoad) []byte { + if rec.State == indexer.RepoLoadIdentityPending { + return d.repoLoadingError(&daemon.Session{}, inbound, rec) + } phase, percent := "starting", 0 if d.srv != nil { phase, percent = d.srv.WarmupProgress() diff --git a/cmd/gortex/daemon_repo_identity_pending_test.go b/cmd/gortex/daemon_repo_identity_pending_test.go new file mode 100644 index 000000000..f2b156c7d --- /dev/null +++ b/cmd/gortex/daemon_repo_identity_pending_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "go.uber.org/zap" +) + +func TestDispatcher_ColdIdentityPendingHasNoInventedScope(t *testing.T) { + d, mi, registered, _ := loadingDispatcherSetup(t) + physical := filepath.Join(t.TempDir(), "physical-root") + require.NoError(t, os.MkdirAll(physical, 0755)) + alias := filepath.Join(t.TempDir(), "different-configured-name") + require.NoError(t, os.Symlink(physical, alias)) + entries := []config.RepoEntry{{Path: registered}, {Path: alias, Name: "private-config-name"}} + mi.DeclareRepos(entries) + for _, cwd := range []string{physical, t.TempDir()} { + sess := &daemon.Session{ID: "identity-" + filepath.Base(cwd), CWD: cwd} + d.SessionStarted(sess, func([]byte) error { return nil }) + t.Cleanup(func() { d.SessionEnded(sess) }) + init := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"identity-test","version":"1"}}}`) + body, err := json.Marshal(init) + require.NoError(t, err) + require.Contains(t, string(body), "identity_pending") + require.NotContains(t, string(body), "INACTIVE") + require.NotContains(t, string(body), "already configured") + require.NotContains(t, string(body), "private-config-name") + require.NotContains(t, string(body), "gortex track") + discover := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":8,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"claude-code","version":"2.1.312"},"io.modelcontextprotocol/clientCapabilities":{}}}}`) + discovery, marshalErr := json.Marshal(discover) + require.NoError(t, marshalErr) + require.Contains(t, string(discovery), "identity_pending") + require.NotContains(t, string(discovery), "INACTIVE") + require.NotContains(t, string(discovery), "private-config-name") + start := time.Now() + reply := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`) + require.Less(t, time.Since(start), 100*time.Millisecond, "negative identity admission never waits for resolution") + data := errorData(t, reply) + require.Equal(t, "repo_loading", data["error_code"]) + require.Equal(t, true, data["retryable"]) + require.Equal(t, "identity_resolution", data["phase"]) + require.EqualValues(t, 0, data["percent"]) + require.Equal(t, "identity_pending", data["reason"]) + require.NotContains(t, data, "repo") + require.NotContains(t, data, "repo_path") + require.NotContains(t, reply, "result") + } + mi.ExpectRepos(entries) + physicalSess := &daemon.Session{ID: "resolved-alias", CWD: physical} + data := errorData(t, dispatchJSON(t, d, physicalSess, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`)) + require.Equal(t, "repo_loading", data["error_code"]) + require.Equal(t, alias, data["repo_path"], "completed owner resolution can now prove configured ownership") + unknown, err := d.cwdReachableChecked(t.Context(), t.TempDir()) + require.NoError(t, err) + require.False(t, unknown, "a completed identity set permits a definitive negative") +} + +func TestDispatcher_IdentityPendingPreservesAuthoritativePositives(t *testing.T) { + d, mi, registered, _ := loadingDispatcherSetup(t) + mi.DeclareRepos([]config.RepoEntry{{Path: registered}, {Path: filepath.Join(t.TempDir(), "unresolved")}}) + require.True(t, mi.RepoIdentityPending()) + ok, err := d.cwdReachableChecked(t.Context(), registered) + require.NoError(t, err) + require.True(t, ok) + checkout := t.TempDir() + d.checkoutCWDProbe = func(_ context.Context, cwd string) (bool, error) { return cwd == checkout, nil } + ok, err = d.cwdReachableChecked(t.Context(), checkout) + require.NoError(t, err) + require.True(t, ok, "an authoritative existing checkout still serves while another root is unresolved") + remote := t.TempDir() + require.NoError(t, writeFile(filepath.Join(remote, ".gortex.yaml"), "workspace: remote-ws\n")) + cfg := &daemon.ServersConfig{Server: []daemon.ServerEntry{ + {Slug: "self", URL: "unix:///tmp/never-dialed.sock", Default: true}, + {Slug: "remote", URL: "https://remote.example.com", Workspaces: []string{"remote-ws"}}, + }} + require.NoError(t, cfg.Validate()) + d.SetRouter(daemon.NewRouter(daemon.RouterConfig{Servers: cfg, Rosters: daemon.NewWorkspaceRosterCache(0), LocalSlug: "self", Logger: zap.NewNop()})) + ok, err = d.cwdReachableChecked(t.Context(), remote) + require.NoError(t, err) + require.True(t, ok, "router authority takes precedence over an unresolved negative") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = d.cwdReachableChecked(ctx, t.TempDir()) + require.ErrorIs(t, err, context.Canceled) +} + +func TestCoverage_IdentityPendingIsNeitherTrackedNorGraphAnswer(t *testing.T) { + _, mi, registered, _ := loadingDispatcherSetup(t) + mi.DeclareRepos([]config.RepoEntry{{Path: registered}, {Path: filepath.Join(t.TempDir(), "unresolved")}}) + c := &realController{multiIndexer: mi} + path := filepath.Join(t.TempDir(), "file.go") + file, err := c.FileCoverage(t.Context(), daemon.FileCoverageParams{Path: path}) + require.NoError(t, err) + require.Equal(t, daemon.RepoStateIdentityPending, file.RepoState) + require.Empty(t, file.Repo) + require.False(t, file.Tracked) + require.False(t, file.Answered) + require.False(t, file.Covered) + dir, err := c.DirCoverage(t.Context(), daemon.DirCoverageParams{Path: filepath.Dir(path)}) + require.NoError(t, err) + require.Equal(t, daemon.RepoStateIdentityPending, dir.RepoState) + require.Empty(t, dir.Repo) + require.False(t, dir.Tracked) + require.False(t, dir.Answered) + require.False(t, dir.HasSource) + healthy, err := c.FileCoverage(t.Context(), daemon.FileCoverageParams{Path: filepath.Join(registered, "a.go")}) + require.NoError(t, err) + require.Empty(t, healthy.RepoState) +} + +func TestExtractToolResult_IdentityPendingDoesNotClaimTracking(t *testing.T) { + _, err := extractToolResult([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"identity pending","data":{"error_code":"repo_loading","retryable":true,"reason":"identity_pending","phase":"identity_resolution","percent":0}}}`)) + var loading *repoLoadingError + require.ErrorAs(t, err, &loading) + require.Equal(t, exitTempFail, loading.ExitCode()) + require.Contains(t, err.Error(), "identity_pending") + require.NotContains(t, err.Error(), "already tracked") + require.NotContains(t, err.Error(), "gortex track") +} + +func TestDispatcher_ColdDarwinTmpAliasUsesIdentityPending(t *testing.T) { + canonical, err := filepath.EvalSymlinks("/tmp") + require.NoError(t, err) + if canonical != "/private/tmp" { + t.Skip("host does not expose the /tmp -> /private/tmp alias") + } + root, err := os.MkdirTemp("/tmp", "g1023-wire-alias-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + d, mi := trackedPathMCPSetup(t, t.TempDir()) + mi.DeclareRepos([]config.RepoEntry{{Path: root}}) + sess := &daemon.Session{ID: "actual-tmp-alias", CWD: filepath.Join(canonical, filepath.Base(root))} + d.SessionStarted(sess, func([]byte) error { return nil }) + t.Cleanup(func() { d.SessionEnded(sess) }) + initialized := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"alias-test","version":"1"}}}`) + raw, err := json.Marshal(initialized) + require.NoError(t, err) + require.Contains(t, string(raw), "identity_pending") + require.NotContains(t, string(raw), "INACTIVE") + data := errorData(t, dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Anything"}}}`)) + require.Equal(t, "repo_loading", data["error_code"]) + require.Equal(t, "identity_pending", data["reason"]) + require.NotContains(t, data, "repo_path") +} diff --git a/internal/daemon/proto.go b/internal/daemon/proto.go index 50c3aaaf1..0425fbf07 100644 --- a/internal/daemon/proto.go +++ b/internal/daemon/proto.go @@ -314,6 +314,9 @@ const ( // RepoStateLoading: configured, and the daemon has not registered it // since it started. Graph calls answer repo_loading until it is ready. RepoStateLoading = "loading" + // RepoStateIdentityPending means configured identities are unresolved; it + // does not claim that the requested path belongs to a repository. + RepoStateIdentityPending = "identity_pending" // RepoStateReady: registered; graph calls answer. RepoStateReady = "ready" // RepoStateFailed: its load ended without registering it. @@ -1011,6 +1014,8 @@ type FileCoverageResult struct { // Graph calls for such a repo answer repo_loading, so a coverage verdict // read from its persisted rows must not turn into a deny of the native // tool: the agent would have no read path at all. Repo names it. + // RepoStateIdentityPending instead reports unresolved configured identities + // without asserting ownership of Path; Repo is empty and no graph is read. RepoState string `json:"repo_state,omitempty"` Repo string `json:"repo,omitempty"` // MutationState is read_only while the current checkout's exact route has diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 7ccf88a8e..761e33878 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -1205,7 +1205,7 @@ func readOnlyNativeAllowance(writing bool) enrichResult { // hook lets it through because the path's repository is still loading. func repoLoadingNativeAllowance(repo string) string { if repo == "" { - repo = "this repository" + return "[Gortex] Configured repository identities are still resolving; graph admission for this path is not yet established (repo_loading, identity_pending). Native tools are allowed while this temporary state resolves. Retry shortly without changing tracking configuration.\n" } return fmt.Sprintf("[Gortex] %s is still loading after a restart; graph tools return repo_loading until it is ready. "+ "Native reads are allowed until then.\n", repo) @@ -1314,7 +1314,7 @@ func fileIndexStatusFromCoverage(result daemon.FileCoverageResult) fileIndexStat st.ProbeOK, st.ReadOnly, st.MutationKnown, st.MutationReason = true, true, true, result.MutationReason } st.MutationKnown = st.MutationKnown || result.MutationState == "ready" - if result.RepoState == daemon.RepoStateLoading { + if result.RepoState == daemon.RepoStateLoading || result.RepoState == daemon.RepoStateIdentityPending { // Coverage read from a loading repository's persisted rows is not a // reason to deny: report it as loading, never as indexed. st.ProbeOK, st.Loading, st.LoadingRepo = true, true, result.Repo @@ -1614,7 +1614,7 @@ var dirCoverageFn = dirCoverageViaDaemon // Only a completed walk that claimed nothing proves a scope holds no source. // From the graph alone a scope mid-walk looks exactly like an excluded one. func scopeTrackedFromCoverage(result daemon.DirCoverageResult) (hasSource, probeOK bool) { - if result.RepoState == daemon.RepoStateLoading { + if result.RepoState == daemon.RepoStateLoading || result.RepoState == daemon.RepoStateIdentityPending { // A loading repository's scope holds nothing the graph will answer // for yet, whatever its persisted rows say (see fileIndexStatus). return false, true diff --git a/internal/hooks/repo_loading_test.go b/internal/hooks/repo_loading_test.go index a3554a72d..21a6725e3 100644 --- a/internal/hooks/repo_loading_test.go +++ b/internal/hooks/repo_loading_test.go @@ -55,3 +55,31 @@ func TestLoadingRepo_SearchScopeAllowed(t *testing.T) { t.Fatalf("a loading repository's scope must read as holding nothing the graph answers for: hasSource=%v probeOK=%v", hasSource, probeOK) } } + +func TestIdentityPending_NativeDoorsDoNotClaimOwnership(t *testing.T) { + st := fileIndexStatusFromCoverage(daemon.FileCoverageResult{RepoState: daemon.RepoStateIdentityPending}) + if !st.Loading || st.Indexed || st.Tracked || st.LoadingRepo != "" { + t.Fatalf("uncertain identity is not an indexed or tracked verdict: %#v", st) + } + stubFileIndexScope(t, st) + for name, res := range map[string]enrichResult{ + "Read": enrichRead(map[string]any{"file_path": "internal/graph/graph.go"}, "/repo"), + "Edit": enrichEdit(map[string]any{"file_path": "internal/graph/graph.go"}, "/repo"), + "Bash cat": enrichBash(map[string]any{"command": "cat internal/graph/graph.go"}, "/repo"), + } { + if res.deny { + t.Fatalf("%s denied while graph admission is unresolved: %#v", name, res) + } + if strings.Contains(res.context, "this repository is still loading") || strings.Contains(res.context, "already configured") { + t.Fatalf("%s invented repository ownership: %s", name, res.context) + } + } + read := enrichRead(map[string]any{"file_path": "internal/graph/graph.go"}, "/repo") + if !strings.Contains(read.context, "identity_pending") { + t.Fatalf("missing honest reason: %s", read.context) + } + hasSource, ok := scopeTrackedFromCoverage(daemon.DirCoverageResult{RepoState: daemon.RepoStateIdentityPending}) + if hasSource || !ok { + t.Fatalf("pending directory must not redirect to graph: source=%v known=%v", hasSource, ok) + } +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index bae3591bb..fde813d19 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -329,7 +329,7 @@ func (mi *MultiIndexer) publishRepoLoadIdentity(before, resolved RepoLoad) { } rec := &records[i] rec.canonicalKey, rec.commonDir, rec.identityPending = resolved.canonicalKey, resolved.commonDir, false - if rec.State.Loading() { + if strings.HasPrefix(rec.Reason, "identity resolution pending: ") { rec.Reason = "" } if rec.State == RepoLoadQueued && resolved.State == RepoLoadMissing { diff --git a/internal/mcp/checkout_binding.go b/internal/mcp/checkout_binding.go index 1e2391137..baa0fcfd9 100644 --- a/internal/mcp/checkout_binding.go +++ b/internal/mcp/checkout_binding.go @@ -771,7 +771,10 @@ func (s *Server) checkoutForRequestPath(ctx context.Context, path string) (store return checkout, true, nil } } - if s.lifecycle == nil { + if s.lifecycle == nil || (s.multiIndexer != nil && s.multiIndexer.RepoIdentityPending()) { + // Registered catalog positives above remain usable. An unresolved configured + // identity is not a reason to allocate a checkout-observation job on a miss; + // startup admission will report identity-pending without guessing ownership. return store_sqlite.Checkout{}, false, parentErr } // CWD observation establishes a session's scope. A different explicit diff --git a/internal/mcp/repo_identity_pending_test.go b/internal/mcp/repo_identity_pending_test.go new file mode 100644 index 000000000..449a7fd1e --- /dev/null +++ b/internal/mcp/repo_identity_pending_test.go @@ -0,0 +1,57 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" +) + +func TestStateAwareInstructions_ColdIdentityPendingAndRecovery(t *testing.T) { + srv, mi, configured := newLoadingServer(t) + physical := wsRepo(t, "physical", "alias-workspace", "AliasSymbol") + alias := filepath.Join(t.TempDir(), "different-configured-name") + require.NoError(t, os.Symlink(physical, alias)) + entries := []config.RepoEntry{{Path: configured}, {Path: alias, Name: "alias-repo"}} + mi.DeclareRepos(entries) + for _, cwd := range []string{physical, t.TempDir()} { + for _, client := range []string{"claude-code", "codex", ""} { + instructions := srv.stateAwareInstructionsForClient(cwd, client) + require.Contains(t, instructions, "identity_pending") + require.NotContains(t, instructions, "INACTIVE") + require.NotContains(t, instructions, "already configured") + require.NotContains(t, instructions, "alias-repo") + require.NotContains(t, instructions, "gortex track") + } + } + ctx := sessionCtx("unresolved-alias", physical) + ws, _, bound := srv.sessionScope(ctx) + require.True(t, bound) + require.True(t, strings.HasPrefix(ws, unresolvedWorkspacePrefix), "uncertainty grants no graph scope") + mi.ExpectRepos(entries) + require.Contains(t, srv.stateAwareInstructions(physical), "LOADING alias-repo") + _, err := mi.TrackRepoCtx(t.Context(), config.RepoEntry{Path: physical, Name: "alias-repo"}) + require.NoError(t, err) + ws, _, bound = srv.sessionScope(ctx) + require.True(t, bound) + require.Equal(t, "alias-workspace", ws, "the same session re-resolves after identity and registration complete") + require.NotContains(t, srv.stateAwareInstructions(physical), "identity_pending") + require.Contains(t, srv.stateAwareInstructions(t.TempDir()), "INACTIVE", "only complete negative identity evidence permits inactive advice") +} + +func TestIdentityPendingPreservesRegisteredAutomaticCheckout(t *testing.T) { + v := newViewStack(t) + v.srv.multiIndexer.DeclareRepos([]config.RepoEntry{{Path: v.repoRoot}, {Path: v.otherRoot}, {Path: filepath.Join(t.TempDir(), "unknown")}}) + require.True(t, v.srv.multiIndexer.RepoIdentityPending()) + served, err := v.srv.CheckoutServesCWDChecked(t.Context(), v.worktreeRoot) + require.NoError(t, err) + require.True(t, served) + require.NotContains(t, v.srv.stateAwareInstructions(v.worktreeRoot), "identity_pending") + ctx := sessionCtx("pending-other-root", v.worktreeRoot) + ws, _, bound := v.srv.sessionScope(ctx) + require.True(t, bound) + require.Equal(t, "main-ws", ws) +} diff --git a/internal/mcp/repo_loading.go b/internal/mcp/repo_loading.go index 7a85efd25..752cd061c 100644 --- a/internal/mcp/repo_loading.go +++ b/internal/mcp/repo_loading.go @@ -36,6 +36,15 @@ func ServerInstructionsLoading(repo, phase string, percent int) string { "the repository is already configured.", repo, loadingPhaseLabel(phase), percent) } +// ServerInstructionsIdentityPending describes uncertainty, not ownership of +// the caller's path. It is shared by initialization and modern discovery. +func ServerInstructionsIdentityPending() string { + return "Gortex is LOADING configured repository identities after a restart (identity_resolution, 0%). " + + "Graph admission for this path is not yet established; calls return repo_loading (retryable, identity_pending). " + + "Retry shortly or continue work natively while identity resolution completes. No graph scope has been granted; " + + "do not change tracking configuration or reconnect to resolve this temporary state." +} + // ServerInstructionsUnavailable is the inactive initialize variant for a // configured repository whose load ended without registering it. func ServerInstructionsUnavailable(repo, reason string) string { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8aad9ca6c..4e6643e79 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1700,6 +1700,9 @@ func (s *Server) stateAwareInstructionsContext(ctx context.Context, cwd, base st } else if serves { return s.appendLiveInstructions(cwd, base) } + if s.multiIndexer.RepoIdentityPending() { + return ServerInstructionsIdentityPending() + "\n\n" + base + } roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) if agents.PendingAutomaticCheckout(cwd, roots) { return ServerInstructionsPendingAutomaticCheckout(cwd, roots...) @@ -2430,7 +2433,7 @@ func (s *Server) sessionScope(ctx context.Context) (workspaceID, projectID strin // too, but only for now. Caching the sentinel would leave the session // blind after the repository registers, so it is not cached: the next // call resolves again. - if rec, pending := s.multiIndexer.PendingRepoForPath(cwd); pending && rec.State.Loading() { + if rec, pending := s.multiIndexer.PendingRepoForPath(cwd); (pending && rec.State.Loading()) || s.multiIndexer.RepoIdentityPending() { ss.scopeResolved = false } ss.scopeWorkspaceID = unresolvedWorkspacePrefix + cwd From 32b7bffbd27c66201e7ad919c37adebb056f9ce9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:30:03 +0100 Subject: [PATCH 21/34] fix(startup): keep unbound diagnostics scoped and fence stale identity results --- cmd/gortex/daemon_mcp_repo_loading.go | 26 +++++++++---------- .../daemon_repo_identity_pending_test.go | 20 ++++++++++++++ internal/indexer/repo_load_identity_test.go | 24 +++++++++++++++++ internal/indexer/repo_load_state.go | 1 + 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/cmd/gortex/daemon_mcp_repo_loading.go b/cmd/gortex/daemon_mcp_repo_loading.go index ce27228c3..ac414711a 100644 --- a/cmd/gortex/daemon_mcp_repo_loading.go +++ b/cmd/gortex/daemon_mcp_repo_loading.go @@ -53,6 +53,9 @@ func (d *mcpDispatcher) pendingRepoRefusal(sess *daemon.Session, frame []byte, r case legacy == "index_health": return d.loadingIndexHealth(frame, rec) case loadingAdmittedTool(legacy): + if rec.State == indexer.RepoLoadIdentityPending && (legacy == "track_repository" || legacy == "index_repository") { + return d.repoLoadingError(sess, frame, rec) + } return nil default: return d.repoLoadingError(sess, frame, rec) @@ -149,24 +152,21 @@ func repoUnavailableError(sess *daemon.Session, inbound []byte, rec indexer.Repo // unbound session must see only its own, so the answer is built here rather // than filtered out of that snapshot. func (d *mcpDispatcher) loadingIndexHealth(inbound []byte, rec indexer.RepoLoad) []byte { - if rec.State == indexer.RepoLoadIdentityPending { - return d.repoLoadingError(&daemon.Session{}, inbound, rec) - } phase, percent := "starting", 0 if d.srv != nil { phase, percent = d.srv.WarmupProgress() } + repos := []map[string]any{} + message := gortexmcp.ServerInstructionsIdentityPending() + reason := "" + if rec.State == indexer.RepoLoadIdentityPending { + phase, percent, reason = "identity_resolution", 0, "identity_pending" + } else { + repos = append(repos, map[string]any{"repo": rec.Prefix, "path": rec.Root, "state": string(rec.State), "since": rec.Since}) + message = gortexmcp.RepoLoadingSuggestion(rec.Label()) + } payload, _ := json.Marshal(map[string]any{ - "status": "loading", - "phase": phase, - "percent": percent, - "repos": []map[string]any{{ - "repo": rec.Prefix, - "path": rec.Root, - "state": string(rec.State), - "since": rec.Since, - }}, - "message": gortexmcp.RepoLoadingSuggestion(rec.Label()), + "status": "loading", "phase": phase, "percent": percent, "repos": repos, "message": message, "reason": reason, }) var req struct { ID json.RawMessage `json:"id"` diff --git a/cmd/gortex/daemon_repo_identity_pending_test.go b/cmd/gortex/daemon_repo_identity_pending_test.go index f2b156c7d..f31239739 100644 --- a/cmd/gortex/daemon_repo_identity_pending_test.go +++ b/cmd/gortex/daemon_repo_identity_pending_test.go @@ -52,6 +52,19 @@ func TestDispatcher_ColdIdentityPendingHasNoInventedScope(t *testing.T) { require.NotContains(t, data, "repo") require.NotContains(t, data, "repo_path") require.NotContains(t, reply, "result") + for _, tool := range []string{ + `"name":"index_health","arguments":{}`, + `"name":"workspace","arguments":{"operation":"index"}`, + } { + health := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{`+tool+`}}`) + text := toolResultText(t, health) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &payload)) + require.Equal(t, "identity_pending", payload["reason"]) + require.Empty(t, payload["repos"], "an unbound diagnostic must not disclose named repositories") + } + track := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"track_repository","arguments":{"path":"."}}}`) + require.Equal(t, "identity_pending", errorData(t, track)["reason"], "unknown ownership must not duplicate a configured alias through bootstrap tracking") } mi.ExpectRepos(entries) physicalSess := &daemon.Session{ID: "resolved-alias", CWD: physical} @@ -70,6 +83,13 @@ func TestDispatcher_IdentityPendingPreservesAuthoritativePositives(t *testing.T) ok, err := d.cwdReachableChecked(t.Context(), registered) require.NoError(t, err) require.True(t, ok) + healthySession := &daemon.Session{ID: "registered-while-identity-pending", CWD: registered} + d.SessionStarted(healthySession, func([]byte) error { return nil }) + t.Cleanup(func() { d.SessionEnded(healthySession) }) + dispatchJSON(t, d, healthySession, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"positive-control","version":"1"}}}`) + healthyReply := dispatchJSON(t, d, healthySession, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`) + require.Empty(t, errorCode(healthyReply)) + require.Contains(t, toolResultText(t, healthyReply), "RegisteredOnly") checkout := t.TempDir() d.checkoutCWDProbe = func(_ context.Context, cwd string) (bool, error) { return cwd == checkout, nil } ok, err = d.cwdReachableChecked(t.Context(), checkout) diff --git a/internal/indexer/repo_load_identity_test.go b/internal/indexer/repo_load_identity_test.go index fcb28fe1e..2a5e28f7f 100644 --- a/internal/indexer/repo_load_identity_test.go +++ b/internal/indexer/repo_load_identity_test.go @@ -191,3 +191,27 @@ func TestRepoIdentityPermissionAndDeviceErrorsAreNotNegativeEvidence(t *testing. }) } } + +func TestRepoIdentityOldPublisherCannotClearNewRegistrationUncertainty(t *testing.T) { + root := t.TempDir() + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: root}} + mi.DeclareRepos(entries) + before := mi.RepoLoads()[0] + resolved, err := resolveRepoLoadIdentity(before) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(root, ".git"), []byte("unreadable identity format"), 0644)) + mi.markRepoRegistered("registered", root) + require.True(t, mi.RepoIdentityPending()) + mi.publishRepoLoadIdentity(before, resolved) + require.True(t, mi.RepoIdentityPending(), "an older successful observation must not replace a newer uncertain registration identity") + rec, found := mi.RepoLoadFor(root) + require.True(t, found) + require.Equal(t, RepoLoadReady, rec.State, "uncertain alias identity does not revoke authoritative graph registration") + require.Contains(t, rec.Reason, "identity resolution pending") + require.NoError(t, os.Remove(filepath.Join(root, ".git"))) + mi.ExpectRepos(entries) + require.False(t, mi.RepoIdentityPending()) + rec, _ = mi.RepoLoadFor(root) + require.Empty(t, rec.Reason) +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index fde813d19..e5b3de66a 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -478,6 +478,7 @@ func (mi *MultiIndexer) markRepoRegistered(prefix, root string) { } records[i].commonDir = "" records[i].identityPending = registered.identityPending + records[i].identityToken = registered.identityToken if registered.identityPending { records[i].Reason = registered.Reason } From 68f5e5defab85ece966a19514194f4ce334cc1e9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:30:42 +0100 Subject: [PATCH 22/34] test(startup): preserve registered checkout alias admission during identity resolution --- .../daemon_repo_identity_pending_test.go | 6 +++--- internal/mcp/repo_identity_pending_test.go | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/cmd/gortex/daemon_repo_identity_pending_test.go b/cmd/gortex/daemon_repo_identity_pending_test.go index f31239739..bca9a213b 100644 --- a/cmd/gortex/daemon_repo_identity_pending_test.go +++ b/cmd/gortex/daemon_repo_identity_pending_test.go @@ -41,7 +41,7 @@ func TestDispatcher_ColdIdentityPendingHasNoInventedScope(t *testing.T) { require.NotContains(t, string(discovery), "INACTIVE") require.NotContains(t, string(discovery), "private-config-name") start := time.Now() - reply := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`) + reply := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"RegisteredOnly"}}}`) require.Less(t, time.Since(start), 100*time.Millisecond, "negative identity admission never waits for resolution") data := errorData(t, reply) require.Equal(t, "repo_loading", data["error_code"]) @@ -68,7 +68,7 @@ func TestDispatcher_ColdIdentityPendingHasNoInventedScope(t *testing.T) { } mi.ExpectRepos(entries) physicalSess := &daemon.Session{ID: "resolved-alias", CWD: physical} - data := errorData(t, dispatchJSON(t, d, physicalSess, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`)) + data := errorData(t, dispatchJSON(t, d, physicalSess, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"RegisteredOnly"}}}`)) require.Equal(t, "repo_loading", data["error_code"]) require.Equal(t, alias, data["repo_path"], "completed owner resolution can now prove configured ownership") unknown, err := d.cwdReachableChecked(t.Context(), t.TempDir()) @@ -87,7 +87,7 @@ func TestDispatcher_IdentityPendingPreservesAuthoritativePositives(t *testing.T) d.SessionStarted(healthySession, func([]byte) error { return nil }) t.Cleanup(func() { d.SessionEnded(healthySession) }) dispatchJSON(t, d, healthySession, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"positive-control","version":"1"}}}`) - healthyReply := dispatchJSON(t, d, healthySession, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"RegisteredOnly"}}}`) + healthyReply := dispatchJSON(t, d, healthySession, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"RegisteredOnly"}}}`) require.Empty(t, errorCode(healthyReply)) require.Contains(t, toolResultText(t, healthyReply), "RegisteredOnly") checkout := t.TempDir() diff --git a/internal/mcp/repo_identity_pending_test.go b/internal/mcp/repo_identity_pending_test.go index 449a7fd1e..b2b28949f 100644 --- a/internal/mcp/repo_identity_pending_test.go +++ b/internal/mcp/repo_identity_pending_test.go @@ -46,12 +46,16 @@ func TestIdentityPendingPreservesRegisteredAutomaticCheckout(t *testing.T) { v := newViewStack(t) v.srv.multiIndexer.DeclareRepos([]config.RepoEntry{{Path: v.repoRoot}, {Path: v.otherRoot}, {Path: filepath.Join(t.TempDir(), "unknown")}}) require.True(t, v.srv.multiIndexer.RepoIdentityPending()) - served, err := v.srv.CheckoutServesCWDChecked(t.Context(), v.worktreeRoot) - require.NoError(t, err) - require.True(t, served) - require.NotContains(t, v.srv.stateAwareInstructions(v.worktreeRoot), "identity_pending") - ctx := sessionCtx("pending-other-root", v.worktreeRoot) - ws, _, bound := v.srv.sessionScope(ctx) - require.True(t, bound) - require.Equal(t, "main-ws", ws) + alias := filepath.Join(t.TempDir(), "registered-checkout-alias") + require.NoError(t, os.Symlink(v.worktreeRoot, alias)) + for _, path := range []string{v.worktreeRoot, alias} { + served, err := v.srv.CheckoutServesCWDChecked(t.Context(), path) + require.NoError(t, err) + require.True(t, served) + require.NotContains(t, v.srv.stateAwareInstructions(path), "identity_pending") + ctx := sessionCtx("pending-other-root-"+filepath.Base(path), path) + ws, _, bound := v.srv.sessionScope(ctx) + require.True(t, bound) + require.Equal(t, "main-ws", ws) + } } From 2e2d7425cf4082829a6c8f14e452ba4077c262d8 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:36:36 +0100 Subject: [PATCH 23/34] test(startup): distinguish unresolved from proven linked-family loading --- internal/indexer/repo_load_state_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/indexer/repo_load_state_test.go b/internal/indexer/repo_load_state_test.go index fe48a6ab8..e6c146aca 100644 --- a/internal/indexer/repo_load_state_test.go +++ b/internal/indexer/repo_load_state_test.go @@ -348,9 +348,9 @@ func TestDeclareRepos_WarmupEnrichmentPreservesRunningState(t *testing.T) { root, worktree := linkedWorktreeLayout(t) entries := []config.RepoEntry{{Path: root}} mi.DeclareRepos(entries) - rec, pending := mi.PendingRepoForPath(filepath.Join(worktree, "pkg")) - require.True(t, pending, "a primary declaration must cover independently resolved linked-worktree markers") - assert.Equal(t, root, rec.Root) + _, pending := mi.PendingRepoForPath(filepath.Join(worktree, "pkg")) + require.False(t, pending, "an unresolved declaration cannot name the worktree's family without request filesystem work") + require.True(t, mi.RepoIdentityPending(), "negative startup admission must remain generically retryable until the owner resolves identity") mi.StartRepoLoad(root) before, _ := mi.RepoLoadFor(root) @@ -360,8 +360,9 @@ func TestDeclareRepos_WarmupEnrichmentPreservesRunningState(t *testing.T) { assert.Equal(t, RepoLoadRunning, after.State) assert.Equal(t, before.Since, after.Since) assert.False(t, after.identityPending) - _, pending = mi.PendingRepoForPath(worktree) + rec, pending := mi.PendingRepoForPath(worktree) assert.True(t, pending) + assert.Equal(t, root, rec.Root, "the owner now proves the linked worktree's configured family") mi.DeclareRepos(nil) assert.Empty(t, mi.RepoLoads(), "removed declarations must not remain loading forever") From 5659be9162d957dd876c2c04465b17a347f2fd58 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:38:59 +0100 Subject: [PATCH 24/34] test(startup): verify generic loading until socket identity owner completes --- cmd/gortex/daemon_startup_loading_test.go | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/gortex/daemon_startup_loading_test.go b/cmd/gortex/daemon_startup_loading_test.go index f279ac796..64c12ee46 100644 --- a/cmd/gortex/daemon_startup_loading_test.go +++ b/cmd/gortex/daemon_startup_loading_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -73,8 +74,12 @@ func TestConfiguredLoadingPublishedBeforeServing(t *testing.T) { } `json:"result"` } require.NoError(t, json.Unmarshal(initialized, &initReply)) - assert.Contains(t, initReply.Result.Instructions, gortexmcp.ServerInstructionsLoading("startup", "starting", 0), - "the first real initialize must carry the loading variant before warmup") + expectedInstructions := gortexmcp.ServerInstructionsLoading("startup", "starting", 0) + if strings.HasPrefix(name, "linked") { + expectedInstructions = gortexmcp.ServerInstructionsIdentityPending() + } + assert.Contains(t, initReply.Result.Instructions, expectedInstructions, + "the first real initialize must carry an honest loading variant before identity warmup") require.NoError(t, client.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`))) frame, err := client.ReadMCPFrame() require.NoError(t, err) @@ -83,6 +88,12 @@ func TestConfiguredLoadingPublishedBeforeServing(t *testing.T) { data := errorData(t, reply) assert.Equal(t, "repo_loading", data["error_code"]) assert.Equal(t, true, data["retryable"]) + if strings.HasPrefix(name, "linked") { + assert.Equal(t, "identity_pending", data["reason"]) + assert.NotContains(t, data, "repo_path", "an unresolved lexical declaration cannot name a linked request's family") + } else { + assert.Equal(t, root, data["repo_path"]) + } assert.Contains(t, data, "phase") assert.Contains(t, data, "percent") assert.NotContains(t, string(frame), "gortex track") @@ -91,6 +102,19 @@ func TestConfiguredLoadingPublishedBeforeServing(t *testing.T) { } assert.Empty(t, mi.GetMetadata("startup"), "pending declarations must never register or admit graph access") untracked := &daemon.Session{ID: "unconfigured", CWD: t.TempDir()} + before := errorData(t, dispatchJSON(t, d, untracked, + `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`)) + assert.Equal(t, "repo_loading", before["error_code"]) + assert.Equal(t, "identity_pending", before["reason"]) + assert.NotContains(t, before, "repo_path") + // The real existing warmup owner now resolves identity. A linked path can + // then name its configured family and an unrelated path is truly negative. + announceConfiguredRepos(state) + linkedSession := &daemon.Session{ID: "resolved-linked", CWD: linked} + after := errorData(t, dispatchJSON(t, d, linkedSession, + `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`)) + assert.Equal(t, "repo_loading", after["error_code"]) + assert.Equal(t, root, after["repo_path"]) assert.Equal(t, "repo_not_tracked", errorCode(dispatchJSON(t, d, untracked, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"Startup"}}}`))) } From 2d7f63c443399887ac2ec2b2c2c0c0b52fae9cc2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:52:35 +0100 Subject: [PATCH 25/34] fix(indexer): classify final startup admission from one snapshot --- .../indexer/repo_load_publication_test.go | 54 +++++++++++++++++++ internal/indexer/repo_load_state.go | 53 +++++++++++++----- 2 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 internal/indexer/repo_load_publication_test.go diff --git a/internal/indexer/repo_load_publication_test.go b/internal/indexer/repo_load_publication_test.go new file mode 100644 index 000000000..732020aa0 --- /dev/null +++ b/internal/indexer/repo_load_publication_test.go @@ -0,0 +1,54 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" +) + +func TestRepoLoadAdmissionUsesOneSnapshotAcrossPublication(t *testing.T) { + physical := t.TempDir() + alias := filepath.Join(t.TempDir(), "configured-alias") + require.NoError(t, os.Symlink(physical, alias)) + mi := newRepoLoadTestMulti(t) + entries := []config.RepoEntry{{Path: alias}} + mi.DeclareRepos(entries) + before := mi.repoLoads.load() + mi.ExpectRepos(entries) + // Publication after an ownership miss must not combine the old miss with + // the new all-resolved flag and produce a definitive untracked answer. + pending, classified := before.admissionForPath(physical) + require.True(t, classified) + require.Equal(t, RepoLoadIdentityPending, pending.State) + require.Empty(t, pending.Root) + loading, classified := mi.RepoLoadAdmissionForPath(physical) + require.True(t, classified) + require.Equal(t, RepoLoadQueued, loading.State) + require.Equal(t, alias, loading.Root) + mi.markRepoRegistered("ready", alias) + ready, classified := mi.RepoLoadAdmissionForPath(physical) + require.True(t, classified) + require.Equal(t, RepoLoadReady, ready.State, "final admission must expose a registration that raced positive checks") + _, pendingOwnership := mi.PendingRepoForPath(physical) + require.False(t, pendingOwnership, "existing ownership-only API must not reinterpret ready as loading") +} + +func TestRepoLoadAdmissionRetainsReadyLinkedFamilyIdentity(t *testing.T) { + root, linked := linkedWorktreeLayout(t) + mi := newRepoLoadTestMulti(t) + mi.ExpectRepos([]config.RepoEntry{{Path: root}}) + mi.markRepoRegistered("ready", root) + rec, classified := mi.RepoLoadAdmissionForPath(linked) + require.True(t, classified) + require.Equal(t, RepoLoadReady, rec.State) + require.Equal(t, root, rec.Root) + _, pending := mi.PendingRepoForPath(linked) + require.False(t, pending, "a ready family is identity evidence, not a pending ownership verdict") + nested := filepath.Join(linked, "nested") + require.NoError(t, os.MkdirAll(filepath.Join(nested, ".git"), 0755)) + _, classified = mi.RepoLoadAdmissionForPath(nested) + require.False(t, classified, "a different nearest git identity cannot inherit ready family evidence") +} diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index e5b3de66a..9bad62756 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -476,7 +476,7 @@ func (mi *MultiIndexer) markRepoRegistered(prefix, root string) { if registered.canonicalKey != "" { records[i].canonicalKey = registered.canonicalKey } - records[i].commonDir = "" + records[i].commonDir = registered.commonDir records[i].identityPending = registered.identityPending records[i].identityToken = registered.identityToken if registered.identityPending { @@ -562,31 +562,55 @@ func (mi *MultiIndexer) PendingRepoForPath(path string) (RepoLoad, bool) { if mi == nil || path == "" { return RepoLoad{}, false } - snap := mi.repoLoads.load() + return mi.repoLoads.load().matchForPath(path, false) +} + +// RepoLoadAdmissionForPath classifies a final negative from one immutable +// snapshot. A matched Ready record is identity proof only: the caller must +// recheck ordinary graph/scope authority or retry, never grant access from it. +// An anonymous identity-pending record names no configured repository. +func (mi *MultiIndexer) RepoLoadAdmissionForPath(path string) (RepoLoad, bool) { + if mi == nil || path == "" { + return RepoLoad{}, false + } + return mi.repoLoads.load().admissionForPath(path) +} + +func (s *repoLoadSnapshot) admissionForPath(path string) (RepoLoad, bool) { + if rec, matched := s.matchForPath(path, true); matched { + return rec, true + } + if s.identityPending { + return RepoLoad{State: RepoLoadIdentityPending}, true + } + return RepoLoad{}, false +} + +func (snap *repoLoadSnapshot) matchForPath(path string, includeReady bool) (RepoLoad, bool) { if len(snap.records) == 0 { return RepoLoad{}, false } abs := absRepoRoot(path) if rec, ok := snap.enclosing(abs); ok { - return rec, !rec.State.Ready() + return rec, includeReady || !rec.State.Ready() } // Until the owner resolves aliases, an unmatched request cannot prove // absence. Do not walk its filesystem merely to manufacture a negative. if snap.identityPending { - if rec, ok := snap.contained(abs); ok { + if rec, ok := snap.contained(abs, includeReady); ok { return rec, true } return RepoLoad{}, false } if canonical := pathkey.CanonicalPath(abs); canonical != abs { if rec, ok := snap.enclosing(canonical); ok { - return rec, !rec.State.Ready() + return rec, includeReady || !rec.State.Ready() } } - if rec, ok := snap.contained(abs); ok { + if rec, ok := snap.contained(abs, includeReady); ok { return rec, true } - if snap.loading == 0 { + if snap.loading == 0 && !includeReady { return RepoLoad{}, false } commonDir := enclosingWorktreeCommonDir(abs) @@ -594,7 +618,7 @@ func (mi *MultiIndexer) PendingRepoForPath(path string) (RepoLoad, bool) { return RepoLoad{}, false } for _, rec := range snap.records { - if rec.State.Loading() && rec.commonDir != "" && pathkey.EqualPaths(rec.commonDir, commonDir) { + if (rec.State.Loading() || (includeReady && rec.State.Ready())) && rec.commonDir != "" && pathkey.EqualPaths(rec.commonDir, commonDir) { return rec, true } } @@ -615,21 +639,22 @@ func (s *repoLoadSnapshot) enclosing(path string) (RepoLoad, bool) { } } -// contained finds a not-ready recorded root below path — the shape of an +// contained finds a recorded root below path — the shape of an // agent opened at the directory above its repositories. A loading root wins -// over an unavailable one, because retrying is the useful answer while any -// repository under path can still become ready. -func (s *repoLoadSnapshot) contained(path string) (RepoLoad, bool) { +// over an unavailable or ready one, because retrying is useful while any +// repository under path can still become ready. The ownership-only public +// lookup excludes ready records; final admission includes them for rechecking. +func (s *repoLoadSnapshot) contained(path string, includeReady bool) (RepoLoad, bool) { var found RepoLoad ok := false for _, rec := range s.records { - if rec.State.Ready() || pathkey.EqualPaths(rec.Root, path) || !pathkey.HasPathPrefix(rec.Root, path) { + if (!includeReady && rec.State.Ready()) || pathkey.EqualPaths(rec.Root, path) || !pathkey.HasPathPrefix(rec.Root, path) { continue } if rec.State.Loading() { return rec, true } - if !ok { + if !ok || (found.State.Ready() && !rec.State.Ready()) { found, ok = rec, true } } From 239e3b9f1057bc594dd208ac930945bf250c612e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:52:35 +0100 Subject: [PATCH 26/34] fix(startup): recheck published ownership before negative admission --- cmd/gortex/daemon_controller_view.go | 36 ++++--- cmd/gortex/daemon_mcp.go | 48 +++++++-- cmd/gortex/daemon_mcp_repo_loading.go | 13 ++- cmd/gortex/daemon_repo_loading.go | 16 ++- cmd/gortex/daemon_repo_publication_test.go | 117 +++++++++++++++++++++ internal/hooks/pretooluse.go | 2 +- internal/mcp/repo_identity_pending_test.go | 60 +++++++++++ internal/mcp/repo_loading.go | 18 +++- internal/mcp/server.go | 55 +++++++--- 9 files changed, 310 insertions(+), 55 deletions(-) create mode 100644 cmd/gortex/daemon_repo_publication_test.go diff --git a/cmd/gortex/daemon_controller_view.go b/cmd/gortex/daemon_controller_view.go index 11ff5f55d..165835c75 100644 --- a/cmd/gortex/daemon_controller_view.go +++ b/cmd/gortex/daemon_controller_view.go @@ -310,6 +310,10 @@ func fallbackProbeView(kind, checkoutID, repoPrefix, reason string) *daemon.Prob // and one whose view is not built yet is answered as uncovered so the hook // allows the native tool through. func (c *realController) FileCoverage(ctx context.Context, p daemon.FileCoverageParams) (daemon.FileCoverageResult, error) { + return c.fileCoverageWithProbe(ctx, p, c.resolveProbeView) +} + +func (c *realController) fileCoverageWithProbe(ctx context.Context, p daemon.FileCoverageParams, resolve func(context.Context, string) probeView) (daemon.FileCoverageResult, error) { if c == nil || p.Path == "" { return daemon.FileCoverageResult{}, nil } @@ -319,7 +323,7 @@ func (c *realController) FileCoverage(ctx context.Context, p daemon.FileCoverage } mutationProbe := c.lifecycle.CaptureCheckoutMutationProbe() - view := c.resolveProbeView(ctx, abs) + view := resolve(ctx, abs) defer view.release() out := daemon.FileCoverageResult{View: view.answer, Tracked: c.pathTracked(abs, view)} @@ -329,11 +333,13 @@ func (c *realController) FileCoverage(ctx context.Context, p daemon.FileCoverage // rows and reports the file covered while every graph call refuses with // repo_loading. The state rides on the reply so the hook can let the // native tool through instead of denying the only read path left. - if repo, loading := c.loadingRepoForPath(abs); loading { - out.RepoState, out.Repo = daemon.RepoStateLoading, repo - } else if !out.Tracked && c.multiIndexer != nil && c.multiIndexer.RepoIdentityPending() { - out.RepoState = daemon.RepoStateIdentityPending - return out, nil // neither a negative coverage answer nor graph admission + if rec, classified := c.pendingRepoLoadForPath(abs); classified { + if rec.State.Loading() && rec.State != indexer.RepoLoadIdentityPending { + out.RepoState, out.Repo = daemon.RepoStateLoading, rec.Label() + } else if !out.Tracked && (rec.State == indexer.RepoLoadIdentityPending || rec.State.Ready()) { + out.RepoState = daemon.RepoStateIdentityPending + return out, nil // unknown or newly ready needs a fresh authoritative probe + } } if !view.servable { return out, nil @@ -417,6 +423,10 @@ const dirCoverageWalkBudget = 500 * time.Millisecond // there. It is the scope verdict a PreToolUse hook turns into a Grep/Glob // deny; both halves are exact rather than sampled. func (c *realController) DirCoverage(ctx context.Context, p daemon.DirCoverageParams) (daemon.DirCoverageResult, error) { + return c.dirCoverageWithProbe(ctx, p, c.resolveProbeView) +} + +func (c *realController) dirCoverageWithProbe(ctx context.Context, p daemon.DirCoverageParams, resolve func(context.Context, string) probeView) (daemon.DirCoverageResult, error) { if c == nil || p.Path == "" { return daemon.DirCoverageResult{}, nil } @@ -425,16 +435,18 @@ func (c *realController) DirCoverage(ctx context.Context, p daemon.DirCoveragePa abs = resolved } - view := c.resolveProbeView(ctx, abs) + view := resolve(ctx, abs) defer view.release() out := daemon.DirCoverageResult{View: view.answer, Tracked: c.pathTracked(abs, view)} // See FileCoverage: a loading repository's scope must not deny Grep. - if repo, loading := c.loadingRepoForPath(abs); loading { - out.RepoState, out.Repo = daemon.RepoStateLoading, repo - } else if !out.Tracked && c.multiIndexer != nil && c.multiIndexer.RepoIdentityPending() { - out.RepoState = daemon.RepoStateIdentityPending - return out, nil // neither a negative coverage answer nor graph admission + if rec, classified := c.pendingRepoLoadForPath(abs); classified { + if rec.State.Loading() && rec.State != indexer.RepoLoadIdentityPending { + out.RepoState, out.Repo = daemon.RepoStateLoading, rec.Label() + } else if !out.Tracked && (rec.State == indexer.RepoLoadIdentityPending || rec.State.Ready()) { + out.RepoState = daemon.RepoStateIdentityPending + return out, nil // unknown or newly ready needs a fresh authoritative probe + } } if !view.servable { return out, nil diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index 3ea1a83fe..e6c95deda 100644 --- a/cmd/gortex/daemon_mcp.go +++ b/cmd/gortex/daemon_mcp.go @@ -450,7 +450,7 @@ func (d *mcpDispatcher) cwdReachableChecked(ctx context.Context, cwd string) (bo if cwd == "" { return true, nil } - if d.isCWDTracked(cwd) { + if d.registeredCWDReachable(cwd) { return true, nil } // Workspace-root fallback: a cwd that CONTAINS tracked repos — an @@ -468,12 +468,6 @@ func (d *mcpDispatcher) cwdReachableChecked(ctx context.Context, cwd string) (bo // left the session with no working call and a remedy (`gortex track // `) that would have indexed every child a second time. if d.multiIndexer != nil { - if _, _, _, ok := d.multiIndexer.ScopeForCWD(cwd); ok { - return true, nil - } - if _, _, ok := d.multiIndexer.ContainedReposScope(cwd); ok { - return true, nil - } // Configured but not registered yet: warmup has not reached the // repository, or its load ended without registering it. Decided // before the checkout and router arms, so a worktree of a loading @@ -509,12 +503,48 @@ func (d *mcpDispatcher) cwdReachableChecked(ctx context.Context, cwd string) (bo if err := ctx.Err(); err != nil { return false, err } - if d.multiIndexer != nil && d.multiIndexer.RepoIdentityPending() { - return false, &repoPendingAdmission{load: indexer.RepoLoad{State: indexer.RepoLoadIdentityPending}} + if d.multiIndexer != nil { + if rec, classified := d.multiIndexer.RepoLoadAdmissionForPath(cwd); classified { + if rec.State.Ready() { + // Registration can publish during the probes above. The load table is + // identity evidence, not graph authority: recheck ordinary scope once. + if d.registeredCWDReachable(cwd) { + return true, nil + } + if probe != nil { + reachable, err := probe(ctx, cwd) + if err != nil || reachable { + return err == nil && reachable, err + } + } + rec = indexer.RepoLoad{State: indexer.RepoLoadIdentityPending, Reason: "scope_binding_pending"} + } + if err := ctx.Err(); err != nil { + return false, err + } + return false, &repoPendingAdmission{load: rec} + } } return false, nil } +// registeredCWDReachable is the existing registry/scope positive authority, +// also rechecked when registration races a negative checkout probe. +func (d *mcpDispatcher) registeredCWDReachable(cwd string) bool { + if d.isCWDTracked(cwd) { + return true + } + if d.multiIndexer != nil { + if _, _, _, ok := d.multiIndexer.ScopeForCWD(cwd); ok { + return true + } + if _, _, ok := d.multiIndexer.ContainedReposScope(cwd); ok { + return true + } + } + return false +} + // isCWDTracked reports whether the proxy's cwd lies inside any tracked // repo. Equal paths or any subdirectory of a tracked root qualify — // e.g. a proxy in ~/projects/myapp/internal counts as tracked when diff --git a/cmd/gortex/daemon_mcp_repo_loading.go b/cmd/gortex/daemon_mcp_repo_loading.go index ac414711a..1ef9298ef 100644 --- a/cmd/gortex/daemon_mcp_repo_loading.go +++ b/cmd/gortex/daemon_mcp_repo_loading.go @@ -108,10 +108,14 @@ func peekFrameToolCall(frame []byte) (string, map[string]any) { // session. Retryable, with progress, and it never suggests tracking. func (d *mcpDispatcher) repoLoadingError(sess *daemon.Session, inbound []byte, rec indexer.RepoLoad) []byte { if rec.State == indexer.RepoLoadIdentityPending { - return jsonRPCErrorFrame(inbound, "configured repository identity resolution is pending", map[string]any{ + reason, phase, suggestion := "identity_pending", "identity_resolution", gortexmcp.ServerInstructionsIdentityPending() + if rec.Reason == "scope_binding_pending" { + reason, phase, suggestion = rec.Reason, "scope_binding", gortexmcp.ServerInstructionsScopeBindingPending() + } + return jsonRPCErrorFrame(inbound, "graph admission is pending", map[string]any{ "error_code": string(gortexmcp.ErrCodeRepoLoading), "retryable": true, "path": sess.CWD, - "state": "identity_pending", "reason": "identity_pending", "phase": "identity_resolution", "percent": 0, - "suggestion": gortexmcp.ServerInstructionsIdentityPending(), + "state": reason, "reason": reason, "phase": phase, "percent": 0, + "suggestion": suggestion, }) } phase, percent := "starting", 0 @@ -161,6 +165,9 @@ func (d *mcpDispatcher) loadingIndexHealth(inbound []byte, rec indexer.RepoLoad) reason := "" if rec.State == indexer.RepoLoadIdentityPending { phase, percent, reason = "identity_resolution", 0, "identity_pending" + if rec.Reason == "scope_binding_pending" { + phase, reason, message = "scope_binding", rec.Reason, gortexmcp.ServerInstructionsScopeBindingPending() + } } else { repos = append(repos, map[string]any{"repo": rec.Prefix, "path": rec.Root, "state": string(rec.State), "since": rec.Since}) message = gortexmcp.RepoLoadingSuggestion(rec.Label()) diff --git a/cmd/gortex/daemon_repo_loading.go b/cmd/gortex/daemon_repo_loading.go index 42c9f1f21..4e3074229 100644 --- a/cmd/gortex/daemon_repo_loading.go +++ b/cmd/gortex/daemon_repo_loading.go @@ -67,16 +67,12 @@ func publicRepoState(state indexer.RepoLoadState) string { return "" } -// loadingRepoForPath names the configured repository abs belongs to when that -// repository is still loading, for the coverage replies the hooks turn into a -// deny. Lock-free; reads no store. -func (c *realController) loadingRepoForPath(abs string) (repo string, loading bool) { +// pendingRepoLoadForPath reads one snapshot: ownership and unresolved identity +// must never be separated by a concurrent warmup publication. Ready records +// still require ordinary coverage authority, not permission from this table. +func (c *realController) pendingRepoLoadForPath(abs string) (indexer.RepoLoad, bool) { if c == nil || c.multiIndexer == nil { - return "", false + return indexer.RepoLoad{}, false } - rec, pending := c.multiIndexer.PendingRepoForPath(abs) - if !pending || !rec.State.Loading() { - return "", false - } - return rec.Label(), true + return c.multiIndexer.RepoLoadAdmissionForPath(abs) } diff --git a/cmd/gortex/daemon_repo_publication_test.go b/cmd/gortex/daemon_repo_publication_test.go new file mode 100644 index 000000000..4e2963464 --- /dev/null +++ b/cmd/gortex/daemon_repo_publication_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/indexer" +) + +func TestDispatcher_IdentityPublicationDuringCheckoutProbe(t *testing.T) { + for _, register := range []bool{false, true} { + name := "identity" + if register { + name = "registration" + } + t.Run(name, func(t *testing.T) { + d, mi, registered, _ := loadingDispatcherSetup(t) + physical := t.TempDir() + require.NoError(t, writeFile(filepath.Join(physical, "source.go"), "package source\nfunc Published() {}\n")) + alias := filepath.Join(t.TempDir(), "configured-alias") + require.NoError(t, os.Symlink(physical, alias)) + entries := []config.RepoEntry{{Path: registered}, {Path: alias, Name: "published"}} + mi.DeclareRepos(entries) + calls := 0 + d.checkoutCWDProbe = func(ctx context.Context, path string) (bool, error) { + calls++ + require.Equal(t, physical, path) + mi.ExpectRepos(entries) + if register { + _, err := mi.TrackRepoCtx(ctx, config.RepoEntry{Path: physical, Name: "published"}) + require.NoError(t, err) + } + return false, nil // The earlier negative probe is now stale. + } + reachable, err := d.cwdReachableChecked(t.Context(), physical) + require.Equal(t, 1, calls) + if register { + require.NoError(t, err) + require.True(t, reachable, "normal registered scope must be rechecked after publication") + } else { + var pending *repoPendingAdmission + require.ErrorAs(t, err, &pending) + require.False(t, reachable) + require.Equal(t, alias, pending.load.Root) + require.Equal(t, indexer.RepoLoadQueued, pending.load.State) + } + }) + } +} + +func TestCoverage_IdentityPublicationDuringPublicViewProbe(t *testing.T) { + for _, door := range []string{"file", "directory"} { + t.Run(door, func(t *testing.T) { + _, mi, registered, _ := loadingDispatcherSetup(t) + physical := t.TempDir() + alias := filepath.Join(t.TempDir(), "configured-alias") + require.NoError(t, os.Symlink(physical, alias)) + entries := []config.RepoEntry{{Path: registered}, {Path: alias, Name: "published"}} + mi.DeclareRepos(entries) + c := &realController{multiIndexer: mi} + probe := func(ctx context.Context, path string) probeView { + view := c.resolveProbeView(ctx, path) + mi.ExpectRepos(entries) + return view + } + if door == "file" { + result, err := c.fileCoverageWithProbe(t.Context(), daemon.FileCoverageParams{Path: filepath.Join(physical, "source.go")}, probe) + require.NoError(t, err) + require.Equal(t, daemon.RepoStateLoading, result.RepoState) + require.Equal(t, "published", result.Repo) + require.False(t, result.Covered) + } else { + result, err := c.dirCoverageWithProbe(t.Context(), daemon.DirCoverageParams{Path: physical}, probe) + require.NoError(t, err) + require.Equal(t, daemon.RepoStateLoading, result.RepoState) + require.Equal(t, "published", result.Repo) + require.False(t, result.HasSource) + } + }) + } +} + +func TestDispatcher_ReadyFamilyIdentityDoesNotGrantUnprovenCheckoutScope(t *testing.T) { + d, mi, registered, primary := loadingDispatcherSetup(t) + linked := t.TempDir() + admin := filepath.Join(primary, ".git", "worktrees", "linked") + require.NoError(t, os.MkdirAll(admin, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(admin, "commondir"), []byte("../.."), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+admin), 0644)) + entries := []config.RepoEntry{{Path: registered}, {Path: primary, Name: "loading"}} + mi.DeclareRepos(entries) + calls := 0 + d.checkoutCWDProbe = func(ctx context.Context, path string) (bool, error) { + calls++ + require.Equal(t, linked, path) + if calls == 1 { + mi.ExpectRepos(entries) + _, err := mi.TrackRepoCtx(ctx, config.RepoEntry{Path: primary, Name: "loading"}) + require.NoError(t, err) + } + return false, nil // No authoritative checkout binding is available yet. + } + sess := &daemon.Session{ID: "registered-family-not-route", CWD: linked} + reply := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"LoadProbe"}}}`) + require.Equal(t, 2, calls, "a matched-ready identity permits only one ordinary authority recheck") + data := errorData(t, reply) + require.Equal(t, "repo_loading", data["error_code"]) + require.Equal(t, "scope_binding_pending", data["reason"]) + require.Equal(t, "scope_binding", data["phase"]) + require.NotContains(t, data, "repo_path") + require.NotContains(t, reply, "result", "family identity alone grants no graph scope") +} diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 761e33878..9e2362eab 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -1205,7 +1205,7 @@ func readOnlyNativeAllowance(writing bool) enrichResult { // hook lets it through because the path's repository is still loading. func repoLoadingNativeAllowance(repo string) string { if repo == "" { - return "[Gortex] Configured repository identities are still resolving; graph admission for this path is not yet established (repo_loading, identity_pending). Native tools are allowed while this temporary state resolves. Retry shortly without changing tracking configuration.\n" + return "[Gortex] Graph admission for this path is still resolving and is not yet established (repo_loading, identity_pending). Native tools are allowed while this temporary state resolves. Retry shortly without changing tracking configuration.\n" } return fmt.Sprintf("[Gortex] %s is still loading after a restart; graph tools return repo_loading until it is ready. "+ "Native reads are allowed until then.\n", repo) diff --git a/internal/mcp/repo_identity_pending_test.go b/internal/mcp/repo_identity_pending_test.go index b2b28949f..2179e02f5 100644 --- a/internal/mcp/repo_identity_pending_test.go +++ b/internal/mcp/repo_identity_pending_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "os" "path/filepath" "strings" @@ -59,3 +60,62 @@ func TestIdentityPendingPreservesRegisteredAutomaticCheckout(t *testing.T) { require.Equal(t, "main-ws", ws) } } + +func TestStateAwareInstructions_PublicationDuringCheckoutLookup(t *testing.T) { + for _, register := range []bool{false, true} { + name := "identity" + if register { + name = "registration" + } + t.Run(name, func(t *testing.T) { + srv, mi, configured := newLoadingServer(t) + physical := wsRepo(t, "physical", "published-workspace", "Published") + alias := filepath.Join(t.TempDir(), "configured-alias") + require.NoError(t, os.Symlink(physical, alias)) + entries := []config.RepoEntry{{Path: configured}, {Path: alias, Name: "published"}} + mi.DeclareRepos(entries) + calls := 0 + instructions := srv.stateAwareInstructionsWithCheckout(t.Context(), physical, serverInstructions, func(ctx context.Context, path string) (bool, error) { + calls++ + require.Equal(t, physical, path) + mi.ExpectRepos(entries) + if register { + _, err := mi.TrackRepoCtx(ctx, config.RepoEntry{Path: physical, Name: "published"}) + require.NoError(t, err) + } + return false, nil + }) + require.Equal(t, 1, calls) + require.NotContains(t, instructions, "INACTIVE") + require.NotContains(t, instructions, "gortex track") + if register { + require.NotContains(t, instructions, "LOADING") + } else { + require.Contains(t, instructions, "LOADING published") + } + }) + } +} + +func TestStateAwareInstructions_ReadyFamilyWithoutScopeStillRetries(t *testing.T) { + srv, mi, primary := newLoadingServer(t) + linked := t.TempDir() + admin := filepath.Join(primary, ".git", "worktrees", "linked") + require.NoError(t, os.MkdirAll(admin, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(admin, "commondir"), []byte("../.."), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+admin), 0644)) + calls := 0 + instructions := srv.stateAwareInstructionsWithCheckout(t.Context(), linked, serverInstructions, func(ctx context.Context, path string) (bool, error) { + calls++ + if calls == 1 { + _, err := mi.TrackRepoCtx(ctx, config.RepoEntry{Path: primary, Name: "repo-l"}) + require.NoError(t, err) + } + return false, nil + }) + require.Equal(t, 2, calls) + require.Contains(t, instructions, "scope_binding_pending") + require.NotContains(t, instructions, "INACTIVE") + require.NotContains(t, instructions, "gortex track") + require.Contains(t, instructions, "No graph scope has been granted") +} diff --git a/internal/mcp/repo_loading.go b/internal/mcp/repo_loading.go index 752cd061c..6de08a523 100644 --- a/internal/mcp/repo_loading.go +++ b/internal/mcp/repo_loading.go @@ -39,10 +39,20 @@ func ServerInstructionsLoading(repo, phase string, percent int) string { // ServerInstructionsIdentityPending describes uncertainty, not ownership of // the caller's path. It is shared by initialization and modern discovery. func ServerInstructionsIdentityPending() string { - return "Gortex is LOADING configured repository identities after a restart (identity_resolution, 0%). " + - "Graph admission for this path is not yet established; calls return repo_loading (retryable, identity_pending). " + - "Retry shortly or continue work natively while identity resolution completes. No graph scope has been granted; " + - "do not change tracking configuration or reconnect to resolve this temporary state." + return serverInstructionsAdmissionPending("identity_resolution", "identity_pending") +} + +// ServerInstructionsScopeBindingPending applies after registration when the +// ordinary checkout/scope authority has not established the path's route yet. +func ServerInstructionsScopeBindingPending() string { + return serverInstructionsAdmissionPending("scope_binding", "scope_binding_pending") +} + +func serverInstructionsAdmissionPending(phase, reason string) string { + return fmt.Sprintf("Gortex is LOADING graph admission after a restart (%s, 0%%). "+ + "Graph admission for this path is not yet established; calls return repo_loading (retryable, %s). "+ + "Retry shortly or continue work natively while admission completes. No graph scope has been granted; "+ + "do not change tracking configuration or reconnect to resolve this temporary state.", phase, reason) } // ServerInstructionsUnavailable is the inactive initialize variant for a diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 4e6643e79..0795fccc3 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1671,6 +1671,12 @@ func (s *Server) stateAwareInstructionsWithBase(cwd, base string) string { } func (s *Server) stateAwareInstructionsContext(ctx context.Context, cwd, base string) string { + return s.stateAwareInstructionsWithCheckout(ctx, cwd, base, s.CheckoutServesCWDChecked) +} + +// Keep the existing checkout lookup explicit so publication during that +// lookup can be exercised without a global hook or changing the transport. +func (s *Server) stateAwareInstructionsWithCheckout(ctx context.Context, cwd, base string, lookup func(context.Context, string) (bool, error)) string { // The reachability test must match the dispatcher's gate exactly. // Telling a session it is INACTIVE while every call succeeds is the // same divergence as the reverse, just quieter. @@ -1682,26 +1688,28 @@ func (s *Server) stateAwareInstructionsContext(ctx context.Context, cwd, base st // untracked: telling it to track itself and reconnect is the // advice that made agents re-track or stop on every restart. if rec, pending := s.multiIndexer.PendingRepoForPath(cwd); pending { - if !rec.State.Loading() { - roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) - if agents.PendingAutomaticCheckout(cwd, roots) { - return ServerInstructionsPendingFamilyLoad(cwd, rec, roots...) - } - return ServerInstructionsUnavailable(rec.Root, rec.Reason) - } - // The ordinary instructions follow the loading preamble: the - // client reads them once, and they must still be right after - // the repository is ready. - phase, percent := s.WarmupProgress() - return ServerInstructionsLoading(rec.Label(), phase, percent) + "\n\n" + base + return s.instructionsForRepoLoad(cwd, base, rec) } - if serves, err := s.CheckoutServesCWDChecked(ctx, cwd); err != nil { + if serves, err := lookup(ctx, cwd); err != nil { return CheckoutAdmissionInstructions(cwd, err) } else if serves { return s.appendLiveInstructions(cwd, base) } - if s.multiIndexer.RepoIdentityPending() { - return ServerInstructionsIdentityPending() + "\n\n" + base + if rec, classified := s.multiIndexer.RepoLoadAdmissionForPath(cwd); classified { + if rec.State.Ready() { + _, _, _, inside = s.multiIndexer.ScopeForCWD(cwd) + _, _, contains = s.multiIndexer.ContainedReposScope(cwd) + if inside || contains { + return s.appendLiveInstructions(cwd, base) + } + if serves, err := lookup(ctx, cwd); err != nil { + return CheckoutAdmissionInstructions(cwd, err) + } else if serves { + return s.appendLiveInstructions(cwd, base) + } + return ServerInstructionsScopeBindingPending() + "\n\n" + base + } + return s.instructionsForRepoLoad(cwd, base, rec) } roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) if agents.PendingAutomaticCheckout(cwd, roots) { @@ -1713,6 +1721,21 @@ func (s *Server) stateAwareInstructionsContext(ctx context.Context, cwd, base st return s.appendLiveInstructions(cwd, base) } +func (s *Server) instructionsForRepoLoad(cwd, base string, rec indexer.RepoLoad) string { + if rec.State == indexer.RepoLoadIdentityPending { + return ServerInstructionsIdentityPending() + "\n\n" + base + } + if !rec.State.Loading() { + roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) + if agents.PendingAutomaticCheckout(cwd, roots) { + return ServerInstructionsPendingFamilyLoad(cwd, rec, roots...) + } + return ServerInstructionsUnavailable(rec.Root, rec.Reason) + } + phase, percent := s.WarmupProgress() + return ServerInstructionsLoading(rec.Label(), phase, percent) + "\n\n" + base +} + func (s *Server) appendLiveInstructions(cwd, base string) string { if facts := s.liveInstructionFacts(cwd); facts != "" { base = base + "\n\n" + facts @@ -2433,7 +2456,7 @@ func (s *Server) sessionScope(ctx context.Context) (workspaceID, projectID strin // too, but only for now. Caching the sentinel would leave the session // blind after the repository registers, so it is not cached: the next // call resolves again. - if rec, pending := s.multiIndexer.PendingRepoForPath(cwd); (pending && rec.State.Loading()) || s.multiIndexer.RepoIdentityPending() { + if _, classified := s.multiIndexer.RepoLoadAdmissionForPath(cwd); classified { ss.scopeResolved = false } ss.scopeWorkspaceID = unresolvedWorkspacePrefix + cwd From ba776aaf547eddfb43496e43a8942839ccbfef3a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:53:11 +0100 Subject: [PATCH 27/34] fix(cli): keep newly published scope admission retryable without tracking claims --- cmd/gortex/cli_daemon.go | 6 +++++- cmd/gortex/cli_repo_loading.go | 6 +++--- cmd/gortex/daemon_repo_publication_test.go | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/gortex/cli_daemon.go b/cmd/gortex/cli_daemon.go index 62161b761..9e6ee8047 100644 --- a/cmd/gortex/cli_daemon.go +++ b/cmd/gortex/cli_daemon.go @@ -140,7 +140,11 @@ func extractToolResult(resp []byte) (json.RawMessage, error) { if repo == "" { repo = data.RepoPath } - return nil, &repoLoadingError{repo: repo, phase: data.Phase, percent: data.Percent, identityPending: data.Reason == "identity_pending"} + admissionReason := "" + if data.Reason == "identity_pending" || data.Reason == "scope_binding_pending" { + admissionReason = data.Reason + } + return nil, &repoLoadingError{repo: repo, phase: data.Phase, percent: data.Percent, admissionReason: admissionReason} case string(gortexmcp.ErrCodeRepoUnavailable): return nil, fmt.Errorf("the gortex daemon could not load %s after its restart (%s). %s", data.RepoPath, data.Reason, data.Suggestion) diff --git a/cmd/gortex/cli_repo_loading.go b/cmd/gortex/cli_repo_loading.go index e96566743..50a82b0fa 100644 --- a/cmd/gortex/cli_repo_loading.go +++ b/cmd/gortex/cli_repo_loading.go @@ -38,7 +38,7 @@ func init() { // It carries exit code 75, so a script or hook that runs the CLI can retry // later without parsing the message. type repoLoadingError struct { - identityPending bool + admissionReason string repo string phase string percent int @@ -51,8 +51,8 @@ func (e *repoLoadingError) Error() string { if e.waited > 0 { msg += fmt.Sprintf("; still loading after waiting %s", e.waited.Round(time.Second)) } - if e.identityPending { - msg = "the gortex daemon is still resolving configured repository identities (repo_loading, identity_pending)" + if e.admissionReason != "" { + msg = fmt.Sprintf("the gortex daemon is still resolving graph admission (repo_loading, %s)", e.admissionReason) if e.waited > 0 { msg += fmt.Sprintf("; still loading after waiting %s", e.waited.Round(time.Second)) } diff --git a/cmd/gortex/daemon_repo_publication_test.go b/cmd/gortex/daemon_repo_publication_test.go index 4e2963464..c3fb3a9d4 100644 --- a/cmd/gortex/daemon_repo_publication_test.go +++ b/cmd/gortex/daemon_repo_publication_test.go @@ -115,3 +115,13 @@ func TestDispatcher_ReadyFamilyIdentityDoesNotGrantUnprovenCheckoutScope(t *test require.NotContains(t, data, "repo_path") require.NotContains(t, reply, "result", "family identity alone grants no graph scope") } + +func TestExtractToolResult_ScopeBindingPendingDoesNotClaimTracking(t *testing.T) { + _, err := extractToolResult([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"scope binding pending","data":{"error_code":"repo_loading","retryable":true,"reason":"scope_binding_pending","phase":"scope_binding","percent":0}}}`)) + var loading *repoLoadingError + require.ErrorAs(t, err, &loading) + require.Equal(t, exitTempFail, loading.ExitCode()) + require.Contains(t, err.Error(), "scope_binding_pending") + require.NotContains(t, err.Error(), "already tracked") + require.NotContains(t, err.Error(), "gortex track") +} From ed102dfb7bbde74df8d725bc73a2acb31bdfd313 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 06:57:12 +0100 Subject: [PATCH 28/34] fix(indexer): preserve pending family priority in final admission --- internal/indexer/repo_load_publication_test.go | 12 ++++++++++++ internal/indexer/repo_load_state.go | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/indexer/repo_load_publication_test.go b/internal/indexer/repo_load_publication_test.go index 732020aa0..78a51a974 100644 --- a/internal/indexer/repo_load_publication_test.go +++ b/internal/indexer/repo_load_publication_test.go @@ -51,4 +51,16 @@ func TestRepoLoadAdmissionRetainsReadyLinkedFamilyIdentity(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(nested, ".git"), 0755)) _, classified = mi.RepoLoadAdmissionForPath(nested) require.False(t, classified, "a different nearest git identity cannot inherit ready family evidence") + // A specific pending family match keeps its existing priority; adding ready + // identity evidence must not displace it merely because ready was recorded first. + loading := t.TempDir() + admin := filepath.Join(root, ".git", "worktrees", "another") + require.NoError(t, os.MkdirAll(admin, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(admin, "commondir"), []byte("../.."), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(loading, ".git"), []byte("gitdir: "+admin), 0644)) + mi.ExpectRepos([]config.RepoEntry{{Path: root}, {Path: loading, Name: "pending-family"}}) + rec, classified = mi.RepoLoadAdmissionForPath(linked) + require.True(t, classified) + require.Equal(t, RepoLoadQueued, rec.State) + require.Equal(t, loading, rec.Root) } diff --git a/internal/indexer/repo_load_state.go b/internal/indexer/repo_load_state.go index 9bad62756..a38845d69 100644 --- a/internal/indexer/repo_load_state.go +++ b/internal/indexer/repo_load_state.go @@ -617,12 +617,20 @@ func (snap *repoLoadSnapshot) matchForPath(path string, includeReady bool) (Repo if commonDir == "" { return RepoLoad{}, false } + var ready RepoLoad + hasReady := false for _, rec := range snap.records { - if (rec.State.Loading() || (includeReady && rec.State.Ready())) && rec.commonDir != "" && pathkey.EqualPaths(rec.commonDir, commonDir) { + if rec.commonDir == "" || !pathkey.EqualPaths(rec.commonDir, commonDir) { + continue + } + if rec.State.Loading() { return rec, true } + if includeReady && rec.State.Ready() && !hasReady { + ready, hasReady = rec, true + } } - return RepoLoad{}, false + return ready, hasReady } // enclosing finds the nearest recorded root at or above path. From 55daec0415304358534c8ebd5f1efa7d83fe1d60 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 07:11:14 +0100 Subject: [PATCH 29/34] fix(startup): preserve verified family discovery classification after ready --- cmd/gortex/daemon_mcp.go | 16 +++++++++++--- cmd/gortex/daemon_mcp_repo_loading.go | 12 +++++++++-- cmd/gortex/daemon_repo_publication_test.go | 25 +++++++++++++++++++--- internal/mcp/repo_identity_pending_test.go | 7 +++--- internal/mcp/server.go | 14 ++++++++++++ 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index e6c95deda..de65fd3df 100644 --- a/cmd/gortex/daemon_mcp.go +++ b/cmd/gortex/daemon_mcp.go @@ -123,7 +123,7 @@ func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, fram admissionErr = nil } var terminalFamilyLoad []indexer.RepoLoad - if pending != nil && !pending.load.State.Loading() && agents.PendingAutomaticCheckout(sess.CWD, d.familyRoots()) { + if pending != nil && pending.load.State.Unavailable() && agents.PendingAutomaticCheckout(sess.CWD, d.familyRoots()) { terminalFamilyLoad = []indexer.RepoLoad{pending.load} pending = nil // still unadmitted; only the guidance/refusal classification changes } @@ -135,7 +135,7 @@ func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, fram return d.familyDiscoveryRefusal(sess.CWD, frame, "family_member"), nil } if pending != nil { - if refused := d.pendingRepoRefusal(sess, frame, pending.load); refused != nil { + if refused := d.pendingRepoRefusal(sess, frame, pending); refused != nil { return refused, nil } } @@ -235,6 +235,9 @@ func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, fram if admissionErr != nil { out = rewriteCheckoutAdmissionResponse(peekFrameMethod(frame), out, sess.CWD, admissionErr) } + if pending != nil && pending.familyInstructions != "" { + out = rewriteMCPAdmissionInstructions(peekFrameMethod(frame), out, pending.familyInstructions) + } if pending != nil { d.notePendingRepoSession(sess, peekFrameMethod(frame), pending.load) } @@ -427,6 +430,10 @@ func checkoutAdmissionError(cwd string, inbound []byte, err error) []byte { } func rewriteCheckoutAdmissionResponse(method string, out []byte, cwd string, err error) []byte { + return rewriteMCPAdmissionInstructions(method, out, gortexmcp.CheckoutAdmissionInstructions(cwd, err)) +} + +func rewriteMCPAdmissionInstructions(method string, out []byte, instructions string) []byte { if method != "initialize" && method != "server/discover" { return out } @@ -438,7 +445,7 @@ func rewriteCheckoutAdmissionResponse(method string, out []byte, cwd string, err if !ok { return out } - result["instructions"] = gortexmcp.CheckoutAdmissionInstructions(cwd, err) + result["instructions"] = instructions updated, marshalErr := json.Marshal(reply) if marshalErr != nil { return out @@ -517,6 +524,9 @@ func (d *mcpDispatcher) cwdReachableChecked(ctx context.Context, cwd string) (bo return err == nil && reachable, err } } + if instructions, known := gortexmcp.PendingAutomaticCheckoutInstructions(cwd, d.familyRoots()...); known { + return false, &repoPendingAdmission{load: rec, familyInstructions: instructions} + } rec = indexer.RepoLoad{State: indexer.RepoLoadIdentityPending, Reason: "scope_binding_pending"} } if err := ctx.Err(); err != nil { diff --git a/cmd/gortex/daemon_mcp_repo_loading.go b/cmd/gortex/daemon_mcp_repo_loading.go index 1ef9298ef..807a470b0 100644 --- a/cmd/gortex/daemon_mcp_repo_loading.go +++ b/cmd/gortex/daemon_mcp_repo_loading.go @@ -17,7 +17,8 @@ import ( // failure of the check: Dispatch unwraps it and answers the session with the // repository's load state instead of treating the cwd as untracked. type repoPendingAdmission struct { - load indexer.RepoLoad + load indexer.RepoLoad + familyInstructions string } func (a *repoPendingAdmission) Error() string { @@ -34,8 +35,15 @@ func (a *repoPendingAdmission) Error() string { // the session gets the untracked rules — capabilities and track still answer, // because tracking again is the repair — with repo_unavailable and the reason // instead of repo_not_tracked. -func (d *mcpDispatcher) pendingRepoRefusal(sess *daemon.Session, frame []byte, rec indexer.RepoLoad) []byte { +func (d *mcpDispatcher) pendingRepoRefusal(sess *daemon.Session, frame []byte, pending *repoPendingAdmission) []byte { + rec := pending.load method := peekFrameMethod(frame) + if pending.familyInstructions != "" { + if method == "tools/call" && !untrackedBootstrapCall(frame) { + return familyDiscoveryError(sess.CWD, frame, "family_pending") + } + return nil + } if !rec.State.Loading() { if method == "tools/call" && !untrackedBootstrapCall(frame) { return repoUnavailableError(sess, frame, rec) diff --git a/cmd/gortex/daemon_repo_publication_test.go b/cmd/gortex/daemon_repo_publication_test.go index c3fb3a9d4..e34f14ff7 100644 --- a/cmd/gortex/daemon_repo_publication_test.go +++ b/cmd/gortex/daemon_repo_publication_test.go @@ -109,11 +109,30 @@ func TestDispatcher_ReadyFamilyIdentityDoesNotGrantUnprovenCheckoutScope(t *test reply := dispatchJSON(t, d, sess, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_symbols","arguments":{"query":"LoadProbe"}}}`) require.Equal(t, 2, calls, "a matched-ready identity permits only one ordinary authority recheck") data := errorData(t, reply) - require.Equal(t, "repo_loading", data["error_code"]) - require.Equal(t, "scope_binding_pending", data["reason"]) - require.Equal(t, "scope_binding", data["phase"]) + require.Equal(t, "view_building", data["error_code"]) + require.Equal(t, "family_pending", data["reason"]) + require.Equal(t, "discovery_pending", data["state"]) require.NotContains(t, data, "repo_path") require.NotContains(t, reply, "result", "family identity alone grants no graph scope") + // Retain the actual typed result before a later filesystem change. Metadata + // rendering and graph refusal must not re-probe and become untracked. + d.checkoutCWDProbe = nil + _, err := d.cwdReachableChecked(t.Context(), linked) + var classified *repoPendingAdmission + require.ErrorAs(t, err, &classified) + require.NotEmpty(t, classified.familyInstructions) + require.NoError(t, os.Remove(filepath.Join(linked, ".git"))) + frame := []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"read","arguments":{}}}`) + require.Contains(t, string(d.pendingRepoRefusal(sess, frame, classified)), `"error_code":"view_building"`) + initialized := rewriteMCPAdmissionInstructions("initialize", []byte(`{"jsonrpc":"2.0","id":8,"result":{"instructions":"INACTIVE"}}`), classified.familyInstructions) + require.Contains(t, string(initialized), "view_building") + require.NotContains(t, string(initialized), "INACTIVE") + unknown := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(unknown, ".git"), 0755)) + require.False(t, mi.RepoIdentityPending()) + outside := &daemon.Session{ID: "foreign-family", CWD: unknown} + negative := dispatchJSON(t, d, outside, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"read","arguments":{}}}`) + require.Equal(t, "repo_not_tracked", errorCode(negative), "a genuine foreign family stays definitively untracked after identity completion") } func TestExtractToolResult_ScopeBindingPendingDoesNotClaimTracking(t *testing.T) { diff --git a/internal/mcp/repo_identity_pending_test.go b/internal/mcp/repo_identity_pending_test.go index 2179e02f5..a054cded4 100644 --- a/internal/mcp/repo_identity_pending_test.go +++ b/internal/mcp/repo_identity_pending_test.go @@ -114,8 +114,9 @@ func TestStateAwareInstructions_ReadyFamilyWithoutScopeStillRetries(t *testing.T return false, nil }) require.Equal(t, 2, calls) - require.Contains(t, instructions, "scope_binding_pending") + require.Contains(t, instructions, "view_building") + require.Contains(t, instructions, "checkout discovery is pending") + require.NotContains(t, instructions, "scope_binding_pending", "verified family pending keeps the canonical existing producer") require.NotContains(t, instructions, "INACTIVE") - require.NotContains(t, instructions, "gortex track") - require.Contains(t, instructions, "No graph scope has been granted") + require.NotContains(t, instructions, "gortex track "+linked) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0795fccc3..bc42cc34d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1576,6 +1576,16 @@ func ServerInstructionsPendingAutomaticCheckout(cwd string, roots ...string) str return appendTrackedRootsDiagnostic(msg+"\n\n"+toolref.TransportClause()+" "+toolref.DegradedModeLine(), roots) } +// PendingAutomaticCheckoutInstructions captures the verified family verdict +// with its canonical rendered guidance. A later reader must not re-probe the +// filesystem and accidentally turn this pending result into untracked advice. +func PendingAutomaticCheckoutInstructions(cwd string, roots ...string) (string, bool) { + if !agents.PendingAutomaticCheckout(cwd, roots) { + return "", false + } + return ServerInstructionsPendingAutomaticCheckout(cwd, roots...), true +} + // ServerInstructionsPendingFamilyLoad retains the terminal load failure as // context, without suggesting a second track operation for a declared family. func ServerInstructionsPendingFamilyLoad(cwd string, load indexer.RepoLoad, roots ...string) string { @@ -1707,6 +1717,10 @@ func (s *Server) stateAwareInstructionsWithCheckout(ctx context.Context, cwd, ba } else if serves { return s.appendLiveInstructions(cwd, base) } + roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) + if instructions, known := PendingAutomaticCheckoutInstructions(cwd, roots...); known { + return instructions + } return ServerInstructionsScopeBindingPending() + "\n\n" + base } return s.instructionsForRepoLoad(cwd, base, rec) From cc9bdd67ab442f29827a1d416c6e8c701000815c Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 10:14:59 +0100 Subject: [PATCH 30/34] test: remove obsolete coordinated bulk capability checklist entry --- internal/indexer/edit_delta_capability_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/indexer/edit_delta_capability_test.go b/internal/indexer/edit_delta_capability_test.go index b6ff50754..ee3dfda06 100644 --- a/internal/indexer/edit_delta_capability_test.go +++ b/internal/indexer/edit_delta_capability_test.go @@ -99,7 +99,6 @@ var editDeltaAbsentCapabilities = map[string]string{ "graph.ContractOwnerScalarInvalidator": "optional companion of ContractOwnerReplacer; the fallback re-derives the scalars through ordinary node writes", "graph.ContractOwnerScalarLiveness": "optional companion of ContractOwnerReplacer; absent, the fallback does not assume liveness and checks it through reads", "graph.ContractStateStore": "per-repository ledger read (index state, file metadata, mtimes, failures, receipts): absent, the engine treats nothing as recorded and takes its conservative path; a delta forces its explicit paths", - "graph.CoordinatedBulkLoader": "whole-repository or administrative operation (full index, repository reset, untrack or cleanup, warm start, vector corpus, bulk load windows, WAL maintenance); not on a file delta's per-save path", "graph.CoverageEnrichmentWriter": "node-keyed enrichment side tables (blame, churn, coverage, releases): their rows for an evicted node are the layer below's, hidden with the node; a delta writes none", "graph.CrossRepoFlagMarker": "cross-repository edge flagging; single-repository delta", "graph.CurrentGenerationRepoEvicter": "whole-repository or administrative operation (full index, repository reset, untrack or cleanup, warm start, vector corpus, bulk load windows, WAL maintenance); not on a file delta's per-save path", From 0a9904268fe4a16643b5375ba80f61e7112a8472 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 10:14:59 +0100 Subject: [PATCH 31/34] test: guard the Darwin temporary directory alias before probing it --- cmd/gortex/daemon_repo_identity_pending_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/gortex/daemon_repo_identity_pending_test.go b/cmd/gortex/daemon_repo_identity_pending_test.go index bca9a213b..ad6913037 100644 --- a/cmd/gortex/daemon_repo_identity_pending_test.go +++ b/cmd/gortex/daemon_repo_identity_pending_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" @@ -147,6 +148,9 @@ func TestExtractToolResult_IdentityPendingDoesNotClaimTracking(t *testing.T) { } func TestDispatcher_ColdDarwinTmpAliasUsesIdentityPending(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("requires Darwin's /tmp -> /private/tmp alias") + } canonical, err := filepath.EvalSymlinks("/tmp") require.NoError(t, err) if canonical != "/private/tmp" { From 8cd1c1e4e4e12146d079fd667ad73633670bb797 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Wed, 28 Oct 2026 10:41:31 +0100 Subject: [PATCH 32/34] test: guard the Darwin repository identity alias fixture --- internal/indexer/repo_load_identity_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/indexer/repo_load_identity_test.go b/internal/indexer/repo_load_identity_test.go index 2a5e28f7f..84cfbe1a9 100644 --- a/internal/indexer/repo_load_identity_test.go +++ b/internal/indexer/repo_load_identity_test.go @@ -3,6 +3,7 @@ package indexer import ( "os" "path/filepath" + "runtime" "syscall" "testing" @@ -31,6 +32,9 @@ func TestRepoIdentityColdAliasPublication(t *testing.T) { } func TestRepoIdentityDarwinTmpAliasPublication(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("requires Darwin's /tmp -> /private/tmp alias") + } canonical, err := filepath.EvalSymlinks("/tmp") require.NoError(t, err) if canonical != "/private/tmp" { From 252b9d2aabf00a800dc88e1b3f6e4f167d28e7e8 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 20:03:50 +0100 Subject: [PATCH 33/34] test(startup): register the janitor ownership fixture through tracking The janitor ownership test arrived from main still seeding its graph with MultiIndexer.IndexAll, which this branch keeps only as an indexer test fixture, so the cmd/gortex test build no longer compiled after the merge. Register the configured repository with TrackRepoCtx instead, the same production path the other daemon fixtures already use. The test asserts janitor and shared-stack lifetime, not batch resolution, so per-repository registration covers what it needs. --- cmd/gortex/daemon_janitor_ownership_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/gortex/daemon_janitor_ownership_test.go b/cmd/gortex/daemon_janitor_ownership_test.go index 271fd0ea7..033a4d1fd 100644 --- a/cmd/gortex/daemon_janitor_ownership_test.go +++ b/cmd/gortex/daemon_janitor_ownership_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "runtime" "strings" "sync" @@ -46,7 +47,7 @@ func TestJanitorCanceledCallerRetainsAdmittedWorkerUntilRealStackClose(t *testin state, err := buildDaemonState(zap.New(core)) // Actual shared cleanup registration. require.NoError(t, err) t.Cleanup(func() { _ = state.shared.Close() }) - _, err = state.multiIndexer.IndexAll() + _, err = state.multiIndexer.TrackRepoCtx(context.Background(), gc.Repos[0]) require.NoError(t, err) nodes := state.graph.GetFileNodes("tracked/main.go") require.NotEmpty(t, nodes) From ac0447a708dab9c363b7dbbc0aa8c466781321b4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 22:04:14 +0100 Subject: [PATCH 34/34] feat(bench): probe startup admission from before the socket exists Startup loading is covered by unit and integration tests only. Nothing records what an agent actually receives while a daemon starts: before its socket exists (including a first boot that runs a schema migration), while configured repositories load, and once they are ready, on a cold start and on warm restarts. The opt-in startup scenario starts probing before the first daemon start. Every second, from the primary, a linked worktree and a directory a servers.toml roster claims, it opens fresh sessions and records the initialize instructions variant, search_symbols, the index-health resource and workspace(operation:"index"); asks the PreToolUse hook about a native Read; and runs `gortex call search_symbols` through a pipe and a pty until each first succeeds in that boot. Every answer keeps error_code, retryable, phase, percent, latency, exit code and whether a refusal advises tracking or reconciling. A watcher stamps socket, readiness and enrichment, and reads the checkout coordinator count for 2 s after the primary registers. --seed-store copies an existing store into the private run so the initial boot is an upgrade; --restarts adds warm restarts. The gate counts answers per surface, directory and window and reports time to ready and time to first success per boot, so `delta` can compare startup between builds. The scenario lives in new files; the shared harness files gain only its registration, and the fixture daemon answers the CLI and hook probes so the whole scenario runs end to end in tests. --- bench/load/README.md | 38 ++ bench/load/client.go | 8 +- bench/load/plan.go | 1 + bench/load/run.go | 8 + bench/load/scenario.go | 3 + bench/load/selftest_test.go | 13 +- bench/load/startup_probe.go | 345 ++++++++++++++++ bench/load/startup_probe_sample.go | 610 ++++++++++++++++++++++++++++ bench/load/startup_probe_summary.go | 247 +++++++++++ bench/load/startup_probe_test.go | 329 +++++++++++++++ bench/load/validate.go | 2 +- 11 files changed, 1601 insertions(+), 3 deletions(-) create mode 100644 bench/load/startup_probe.go create mode 100644 bench/load/startup_probe_sample.go create mode 100644 bench/load/startup_probe_summary.go create mode 100644 bench/load/startup_probe_test.go diff --git a/bench/load/README.md b/bench/load/README.md index c79f6188a..5739bd244 100644 --- a/bench/load/README.md +++ b/bench/load/README.md @@ -138,6 +138,44 @@ index; queryability and enrichment are separate observed upper bounds with a recorded polling interval. Older daemons without `loaded_roots` retain their legacy receipt and must still pass source-backed per-root checks. +## Startup admission + +`--scenario startup` (never part of `all`) measures what agents see while a +daemon starts. Every manifest repository is configured; `--worktrees` linked +checkouts of the first one are discovered through it. Probing starts before +the first daemon start, so the window before the socket exists is observed +too. `--seed-store ` copies a store (for example one written by an +older schema) into the private run first, which makes the initial boot an +upgrade; the source is only read. `--restarts N` adds N warm restarts. Each +boot lasts until every configured repository is ready and enriched, plus +`--duration`. + +Every second, from the primary, the first linked worktree, and a directory +whose `.gortex.yaml` names a workspace that a private `servers.toml` roster +claims, the probe opens fresh sessions: `initialize` (the instructions +variant is recorded), `search_symbols` and `resources/read +gortex://index-health` on the legacy surface, and `workspace(operation: +"index")` on the compact one. The roster's only server never listens, so a +routed call falls back to the daemon itself. From the same directories it +asks the PreToolUse hook about a native Read every second, and runs `gortex +call search_symbols` through a pipe (from the start) and through a pty +(`script(1)`, once the socket answers), each until its first success in that +boot. Every answer keeps `error_code`, `retryable`, phase, percent, latency, +exit code, and whether any refusal text advises `gortex track` or +`gortex repos reconcile`. A watcher polls the readiness probe every 100 ms +and, for 2 s after the primary registers, the checkout coordinator count. + +`startup-probe.jsonl` holds every answer. The phase gate `startup_probe` +counts answers per surface and directory in three windows (no socket, +loading, ready) and records each boot's time to ready and time to first +success. Headline metrics include `startup_repo_not_tracked_before_ready`, +`startup_nonconforming_loading_refusals`, the track-advice and hook-deny +counts, `startup_restart_ready_to_first_ok_p95_ms`, +`startup_restart_time_to_ready_p95_ms`, `startup_initial_time_to_ready_ms`, +`startup_initial_first_ok_ms` and `startup_coordinators_within_2s_min`. +Restart figures are unavailable unless every restart produced them, and +`delta` compares two startup reports like any other. + ## Measurements and artifacts One JSON file contains per-operation successful p50/p95/p99/max, successful diff --git a/bench/load/client.go b/bench/load/client.go index a5e972065..081969e7d 100644 --- a/bench/load/client.go +++ b/bench/load/client.go @@ -66,6 +66,12 @@ func readWireLine(reader *bufio.Reader) ([]byte, error) { } func dialSocket(ctx context.Context, socket, cwd, mode string) (net.Conn, *bufio.Reader, string, error) { + return dialSocketSurface(ctx, socket, cwd, mode, "facade-v1", "hide") +} + +// dialSocketSurface negotiates the session's tool surface in the handshake, +// as the CLI does per connection; the load workload always uses the facade. +func dialSocketSurface(ctx context.Context, socket, cwd, mode, tools, toolsMode string) (net.Conn, *bufio.Reader, string, error) { conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socket) if err != nil { return nil, nil, "", err @@ -80,7 +86,7 @@ func dialSocket(ctx context.Context, socket, cwd, mode string) (net.Conn, *bufio } stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) defer stop() - handshake := map[string]any{"version": 1, "mode": mode, "cwd": cwd, "client": "gortex-load", "tools": "facade-v1", "tools_mode": "hide"} + handshake := map[string]any{"version": 1, "mode": mode, "cwd": cwd, "client": "gortex-load", "tools": tools, "tools_mode": toolsMode} if err := json.NewEncoder(conn).Encode(handshake); err != nil { _ = conn.Close() return nil, nil, "", err diff --git a/bench/load/plan.go b/bench/load/plan.go index 15bf13f47..5d8d52b8b 100644 --- a/bench/load/plan.go +++ b/bench/load/plan.go @@ -193,6 +193,7 @@ func validateAgentCounts(scenario string, readers, editors int) error { if readers < 1 { return errors.New("zero pure readers are supported only for edit/many-edit") } + case startupScenario: // probes only; reader and editor counts are unused default: return fmt.Errorf("unknown scenario %q", scenario) } diff --git a/bench/load/run.go b/bench/load/run.go index 4ccc46c98..0dac0194c 100644 --- a/bench/load/run.go +++ b/bench/load/run.go @@ -34,6 +34,7 @@ type runOptions struct { KeepState bool SweepRepetitions int SweepBudget time.Duration + Startup startupOptions pairedDriver func(context.Context, *privateRun, []*socketClient, []*socketClient, *Phase) error } @@ -71,6 +72,7 @@ func bindRunFlags(f *flag.FlagSet, o *runOptions) { f.BoolVar(&o.KeepState, "keep-state", false, "retain private corpus/store for native diagnostics only (default removes completed scenario state)") f.IntVar(&o.SweepRepetitions, "sweep-repetitions", 15, "samples per benchmark operation (smoke may explicitly use 1)") f.DurationVar(&o.SweepBudget, "sweep-duration", 10*time.Minute, "separate total budget for the repeated paced sweep") + bindStartupFlags(f, &o.Startup) } func validateRunOptions(o runOptions) error { @@ -80,6 +82,9 @@ func validateRunOptions(o runOptions) error { if err := validateAgentCounts(o.Scenario, o.Agents, o.Editors); err != nil { return err } + if err := o.Startup.validate(o.Scenario); err != nil { + return err + } if _, err := parseProfile(o.Profile); err != nil { return err } @@ -111,6 +116,9 @@ func validateRunOptions(o runOptions) error { func selectedScenarios(o runOptions) ([]string, error) { all := []string{"read", "edit", "many", "many-edit", "worktrees", "cold", "restart", "sweep"} + if o.Scenario == startupScenario { + return []string{startupScenario}, nil + } if o.Scenario == "all" { if o.Host == "floor" { return []string{"read", "edit", "worktrees", "cold", "restart", "sweep"}, nil diff --git a/bench/load/scenario.go b/bench/load/scenario.go index 38c291626..bcf631ffb 100644 --- a/bench/load/scenario.go +++ b/bench/load/scenario.go @@ -14,6 +14,9 @@ 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) { + if scenario == startupScenario { + return runStartupPhase(ctx, o, corpora, outputRoot) + } phase = Phase{Name: scenario, SessionModel: "per_agent", Started: time.Now().UTC(), Gate: make(map[string]json.RawMessage), Metrics: make(map[string]Metric), EditLatency: distribution(nil, "ms"), Visibility: distribution(nil, "ms")} artifacts, crashes = make(map[string]string), make(map[string]int) var scenarioGroupStart map[string]Metric diff --git a/bench/load/selftest_test.go b/bench/load/selftest_test.go index 9ebf11310..eb30de629 100644 --- a/bench/load/selftest_test.go +++ b/bench/load/selftest_test.go @@ -28,6 +28,17 @@ func fixtureDaemon(mode string) int { fmt.Fprintln(os.Stderr, "panic: synthetic daemon crash") return 2 } + // The startup probe runs the CLI and the hook through the same binary; + // answer them as against a ready daemon instead of starting another one. + for _, arg := range os.Args[1:] { + switch arg { + case "call": + fmt.Println(`{"results":[]}`) + return 0 + case "hook": + return 0 + } + } data, err := os.ReadFile(filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "gortex", "config.yaml")) if err != nil { return 3 @@ -80,7 +91,7 @@ func fixtureDaemon(mode string) int { } result = map[string]any{"ready": true, "enriched": true, "tracked_repos": repos, "loaded_roots": roots} case "status": - result = map[string]any{"ready": true, "enriched": true, "views": map[string]any{}, "storage": map[string]any{"wal_pending_frames": 0}} + result = map[string]any{"ready": true, "enriched": true, "views": map[string]any{"coordinators": 1}, "storage": map[string]any{"wal_pending_frames": 0}} case "shutdown": result = map[string]any{"stopping": true} default: diff --git a/bench/load/startup_probe.go b/bench/load/startup_probe.go new file mode 100644 index 000000000..37bc3f4fc --- /dev/null +++ b/bench/load/startup_probe.go @@ -0,0 +1,345 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// The startup scenario observes repository admission from before the +// daemon's socket exists until every configured repository is ready and +// enriched, on an initial boot and on each optional restart. The probes keep +// sampling across the gap between boots, so a restart is seen exactly as an +// agent sees it. A seeded store makes the initial boot an upgrade: its +// migration runs before the socket listens. + +const ( + startupScenario = "startup" + startupRosterWorkspace = "probe-roster" + startupWatchInterval = 100 * time.Millisecond + startupCoordinatorWindow = 2 * time.Second +) + +type startupOptions struct { + Restarts int + SeedStore string +} + +func bindStartupFlags(f *flag.FlagSet, o *startupOptions) { + f.IntVar(&o.Restarts, "restarts", 0, "startup: warm restarts after the initial boot, each probed from before its socket exists") + f.StringVar(&o.SeedStore, "seed-store", "", "startup: store file copied into the private run before the initial boot, e.g. one written by an older schema; the source is only read") +} + +func (o startupOptions) validate(scenario string) error { + if o.Restarts < 0 { + return errors.New("--restarts must be nonnegative") + } + if scenario != startupScenario && (o.Restarts != 0 || o.SeedStore != "") { + return errors.New("--restarts and --seed-store apply to the startup scenario only") + } + return nil +} + +// bootTiming holds what the readiness watcher observed for one daemon start. +// Every instant is an observed upper bound at the watcher's poll interval. +type bootTiming struct { + Boot int `json:"boot"` + Started time.Time `json:"started"` + Stopping time.Time `json:"stopping"` + SocketAt time.Time `json:"socket_at"` + ReadyAt map[string]time.Time `json:"ready_at"` + EnrichedAt time.Time `json:"enriched_at"` + // Coordinators is the largest checkout coordinator count the status + // census read in the 2 s after the primary registered. + Coordinators *int `json:"coordinators_within_2s"` + CensusReads int `json:"census_reads"` + CensusErrors []string `json:"census_errors"` + Views json.RawMessage `json:"views"` +} + +func runStartupPhase(ctx context.Context, o runOptions, corpora []Corpus, outputRoot string) (phase Phase, _ []SweepEntry, artifacts map[string]string, crashes map[string]int, version string, resultErr error) { + phase = Phase{Name: startupScenario, SessionModel: "fresh_session_per_probe_tick", Started: time.Now().UTC(), Gate: make(map[string]json.RawMessage), Metrics: make(map[string]Metric), EditLatency: distribution(nil, "ms"), Visibility: distribution(nil, "ms")} + artifacts, crashes = make(map[string]string), make(map[string]int) + phase.Operations = startupOperations(corpora[0]) + defer func() { + phase.DurationSec = time.Since(phase.Started).Seconds() + if resultErr != nil { + phase.Errors = append(phase.Errors, resultErr.Error()) + } else if len(phase.Errors) > 0 { + resultErr = errors.New(strings.Join(phase.Errors, "; ")) + } + }() + p, err := newPrivateRun(ctx, outputRoot, o.Binary, corpora) + if err != nil { + return phase, nil, artifacts, crashes, "", err + } + artifacts["private_root"] = p.Root + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), o.StopTimeout) + _, forced, err := p.stop(stopCtx) + cancel() + if forced { + err = errors.Join(err, errors.New("final stop was forced")) + } + logArtifacts, logCrashes, logErr := collectLogs(p.Root) + for name, path := range logArtifacts { + artifacts[name] = path + } + for name, count := range logCrashes { + crashes[name] += count + } + resultErr = errors.Join(resultErr, err, logErr, os.RemoveAll(p.SocketDir)) + if !o.KeepState { + resultErr = errors.Join(resultErr, p.removeState()) + } + }() + linked, err := addLinkedCheckouts(ctx, p, o.Worktrees) + if err != nil { + return phase, nil, artifacts, crashes, "", err + } + routerDir, err := writeRouterRoster(p) + if err != nil { + return phase, nil, artifacts, crashes, "", err + } + configured, err := p.writeBootRepos(false) + if err != nil { + return phase, nil, artifacts, crashes, "", err + } + if o.Startup.SeedStore != "" { + if err := copySeedStore(o.Startup.SeedStore, p.Store); err != nil { + return phase, nil, artifacts, crashes, "", err + } + } + cwds := []probeCWD{{Role: "primary", Dir: p.Repos[0].Path, File: p.Repos[0].File}} + cwds = append(cwds, linked[:min(1, len(linked))]...) + cwds = append(cwds, probeCWD{Role: "router", Dir: routerDir, File: p.Repos[0].File}) + logPath := filepath.Join(p.Root, "startup-probe.jsonl") + probe, err := newStartupProbe(p, cwds, corpora[0].Query, logPath) + if err != nil { + return phase, nil, artifacts, crashes, "", err + } + artifacts["startup_probe_log"] = logPath + probeCtx, stopProbe := context.WithCancel(ctx) + probe.start(probeCtx) + var boots []bootTiming + for index := 0; index <= o.Startup.Restarts; index++ { + if index > 0 { + probe.stopping() + boots[index-1].Stopping = time.Now() + stopCtx, cancel := context.WithTimeout(ctx, o.StopTimeout) + _, forced, err := p.stop(stopCtx) + cancel() + if err != nil || forced { + resultErr = fmt.Errorf("restart %d: stop failed or was forced: %v", index, err) + break + } + } + timing, err := runStartupBoot(ctx, o, p, probe, configured, index) + boots = append(boots, timing) + if err != nil { + resultErr = err + break + } + } + if resultErr == nil { + receiptCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + client, err := openMCP(receiptCtx, p.Socket, p.Repos[0].Path) + if err == nil { + captureReceipt(receiptCtx, p, client, &phase) + err = client.Close() + } + cancel() + resultErr = err + } + if len(boots) > 0 && boots[len(boots)-1].Stopping.IsZero() { + boots[len(boots)-1].Stopping = time.Now() + } + stopProbe() + samples, probeErr := probe.finish() + resultErr = errors.Join(resultErr, probeErr) + summary := summarizeStartup(samples, boots, p.Repos[0].Corpus.Label, len(linked)) + summary.CLITTY = probe.ttyStatus + for name, metric := range summary.metrics() { + phase.Metrics[name] = metric + } + if data, err := json.Marshal(summary); err == nil { + phase.Gate["startup_probe"] = data + } else { + resultErr = errors.Join(resultErr, err) + } + return phase, nil, artifacts, crashes, probe.daemonVersion(), resultErr +} + +// runStartupBoot starts the daemon and follows it until every configured +// repository is ready, the daemon reports enrichment, and the measured tail +// (--duration) has passed, so the next restart begins from a warm store. +func runStartupBoot(ctx context.Context, o runOptions, p *privateRun, probe *startupProbe, configured []privateRepo, index int) (bootTiming, error) { + timing := bootTiming{Boot: index, ReadyAt: make(map[string]time.Time)} + timing.Started = time.Now() + probe.beginBoot(index) + if err := p.start(); err != nil { + return timing, err + } + primary := configured[0].Corpus.Label + for { + probeCtx, cancel := context.WithTimeout(ctx, time.Second) + data, err := control(probeCtx, p.Socket, "probe", nil) + cancel() + now := time.Now() // after the answer: an upper bound, never early + if err == nil { + if timing.SocketAt.IsZero() { + timing.SocketAt = now + probe.socketUp(index) + } + var state readiness + if err := json.Unmarshal(data, &state); err != nil { + return timing, err + } + if err := observeReadiness(&timing, state, configured, now); err != nil { + return timing, err + } + } + ready, registered := timing.ReadyAt[primary] + if registered && now.Sub(ready) <= startupCoordinatorWindow { + readCoordinators(ctx, p, &timing) + } + if !timing.EnrichedAt.IsZero() && now.Sub(timing.EnrichedAt) >= o.Duration && now.Sub(ready) > startupCoordinatorWindow { + return timing, nil + } + select { + case <-p.done: + return timing, fmt.Errorf("boot %d: daemon exited: %v", index, p.exitErr) + default: + } + if now.Sub(timing.Started) > o.WarmTimeout { + return timing, fmt.Errorf("boot %d: configured repositories not ready and enriched within %s", index, o.WarmTimeout) + } + if err := waitContext(ctx, startupWatchInterval); err != nil { + return timing, err + } + } +} + +// observeReadiness stamps each configured repository the first time the probe +// lists it as ready. Daemons that predate per-repository state list a +// registered repository with its prefix and no state. +func observeReadiness(timing *bootTiming, state readiness, configured []privateRepo, now time.Time) error { + for _, repo := range configured { + if _, seen := timing.ReadyAt[repo.Corpus.Label]; seen { + continue + } + for _, tracked := range state.TrackedRepos { + if filepath.Clean(tracked.Path) == filepath.Clean(repo.Path) && tracked.Prefix != "" && (tracked.State == "ready" || tracked.State == "") { + timing.ReadyAt[repo.Corpus.Label] = now + } + } + } + covered, err := readyCoverage(state, configured) + if err == nil && covered && state.Ready && state.Enriched && timing.EnrichedAt.IsZero() && len(timing.ReadyAt) == len(configured) { + timing.EnrichedAt = now + } + return err +} + +func readCoordinators(ctx context.Context, p *privateRun, timing *bootTiming) { + statusCtx, cancel := context.WithTimeout(ctx, time.Second) + data, err := control(statusCtx, p.Socket, "status", nil) + cancel() + timing.CensusReads++ + var status struct { + Views json.RawMessage `json:"views"` + } + var views struct { + Coordinators *int `json:"coordinators"` + } + if err == nil { + err = json.Unmarshal(data, &status) + } + if err == nil && len(status.Views) > 0 { + err = json.Unmarshal(status.Views, &views) + } + if err == nil && views.Coordinators == nil { + err = errors.New("status has no checkout coordinator census") + } + if err != nil { + if len(timing.CensusErrors) < 5 { + timing.CensusErrors = append(timing.CensusErrors, err.Error()) + } + return + } + timing.Views = status.Views + if timing.Coordinators == nil || *views.Coordinators > *timing.Coordinators { + timing.Coordinators = views.Coordinators + } +} + +// addLinkedCheckouts adds linked worktrees of the first manifest repository. +// They are not configured: the daemon discovers them through the primary. +func addLinkedCheckouts(ctx context.Context, p *privateRun, count int) ([]probeCWD, error) { + primary := p.Repos[0] + var linked []probeCWD + for i := range count { + path := filepath.Join(p.Root, "repos", fmt.Sprintf("linked-%d", i+1)) + if _, err := p.git(ctx, primary.Path, "worktree", "add", "--detach", path, primary.Corpus.Commit); err != nil { + return nil, err + } + file, err := ownedFile(path, primary.Corpus.File) + if err != nil { + return nil, err + } + linked = append(linked, probeCWD{Role: "worktree", Dir: path, File: file}) + } + return linked, nil +} + +// writeRouterRoster gives one cwd inside the primary a workspace that a +// servers.toml roster claims. The roster's only server is a socket that never +// exists, so a routed call fails to dial and the daemon answers locally; the +// primary and worktree cwds keep their ordinary path. +func writeRouterRoster(p *privateRun) (string, error) { + dir := filepath.Join(p.Repos[0].Path, ".gortex-probe-router") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(dir, ".gortex.yaml"), []byte("workspace: "+startupRosterWorkspace+"\n"), 0o600); err != nil { + return "", err + } + configDir := filepath.Join(p.Root, "config", "gortex") + if err := os.MkdirAll(configDir, 0o700); err != nil { + return "", err + } + remote := "unix://" + filepath.ToSlash(filepath.Join(p.Root, "absent-remote.sock")) + roster := fmt.Sprintf("[[server]]\nslug = \"probe-remote\"\nurl = %q\nworkspaces = [%q]\n", remote, startupRosterWorkspace) + return dir, os.WriteFile(filepath.Join(configDir, "servers.toml"), []byte(roster), 0o600) +} + +// copySeedStore copies a quiescent store, and its write-ahead log when there +// is one, into the private run. The source is opened read-only. +func copySeedStore(source, dest string) error { + for _, suffix := range []string{"", "-wal"} { + in, err := os.Open(source + suffix) + if suffix != "" && errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return fmt.Errorf("seed store: %w", err) + } + out, err := os.OpenFile(dest+suffix, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + _ = in.Close() + return fmt.Errorf("seed store: %w", err) + } + _, err = io.Copy(out, in) + if err = errors.Join(err, in.Close(), out.Close()); err != nil { + return fmt.Errorf("seed store: %w", err) + } + } + return nil +} diff --git a/bench/load/startup_probe_sample.go b/bench/load/startup_probe_sample.go new file mode 100644 index 000000000..0b6b01eb0 --- /dev/null +++ b/bench/load/startup_probe_sample.go @@ -0,0 +1,610 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const ( + startupProbeInterval = time.Second + startupCallTimeout = 10 * time.Second + // An interactive CLI call waits up to 30 s for a loading repository. + startupCLITimeout = 45 * time.Second + startupTextLimit = 240 +) + +// probeCWD is one directory an agent could start in, and an indexed file the +// native-Read hook probe reads from there. +type probeCWD struct { + Role string // primary, worktree or router + Dir string + File string +} + +// probeSample is one answer as the per-second log records it. Outcome fields +// come from the public wire only: JSON-RPC error data, tool results, CLI exit +// codes and output, and the hook's decision. +type probeSample struct { + CWD string `json:"cwd"` + Surface string `json:"surface"` + Started time.Time `json:"started"` + LatencyMS float64 `json:"latency_ms"` + OK bool `json:"ok"` + Code string `json:"error_code,omitempty"` + Retryable *bool `json:"retryable,omitempty"` + Phase string `json:"phase,omitempty"` + Percent *int `json:"percent,omitempty"` + Status string `json:"status,omitempty"` + Variant string `json:"variant,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + TrackAdvice bool `json:"track_advice,omitempty"` + Text string `json:"text,omitempty"` +} + +func (s probeSample) completed() time.Time { + return s.Started.Add(time.Duration(s.LatencyMS * float64(time.Millisecond))) +} + +// startupOperations is the offered MCP probe plan. The same values build the +// frames, so the plan hash describes exactly what was sent. +func startupOperations(c Corpus) []request { + return []request{ + {Tool: "initialize"}, + {Tool: "search_symbols", Arguments: map[string]any{"query": c.Query, "limit": 1}}, + {Tool: "resources/read", Operation: "index-health", Arguments: map[string]any{"uri": "gortex://index-health"}}, + {Tool: "workspace", Operation: "index", Arguments: map[string]any{"operation": "index"}}, + } +} + +type startupProbe struct { + socket, binary, query string + env []string + cwds []probeCWD + plan []request + ttyStatus string + workers sync.WaitGroup + + mu sync.Mutex + samples []probeSample + log *os.File + encoder *json.Encoder + logErr error + boot int + socketOn bool + version string +} + +func newStartupProbe(p *privateRun, cwds []probeCWD, query, logPath string) (*startupProbe, error) { + log, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, err + } + s := &startupProbe{socket: p.Socket, binary: p.Binary, query: query, env: p.Env, cwds: cwds, plan: startupOperations(Corpus{Query: query}), log: log, encoder: json.NewEncoder(log), boot: -1} + s.ttyStatus = "script(1) pty" + if _, err := ttyCommand(context.Background(), "true", nil); err != nil { + s.ttyStatus = "unavailable: " + err.Error() + } else if _, err := exec.LookPath("script"); err != nil { + s.ttyStatus = "unavailable: " + err.Error() + } + return s, nil +} + +// start launches every sampler. They run until ctx ends, across boots. +func (s *startupProbe) start(ctx context.Context) { + for _, cwd := range s.cwds { + s.every(ctx, func(ctx context.Context) { s.mcpTick(ctx, cwd) }) + s.every(ctx, func(ctx context.Context) { s.recordLive(ctx, s.hookRead(ctx, cwd)) }) + s.cliLoop(ctx, cwd, false) + if strings.HasPrefix(s.ttyStatus, "script") { + s.cliLoop(ctx, cwd, true) + } + } +} + +func (s *startupProbe) every(ctx context.Context, tick func(context.Context)) { + s.workers.Go(func() { + ticker := time.NewTicker(startupProbeInterval) + defer ticker.Stop() + for ctx.Err() == nil { + tick(ctx) + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + }) +} + +// cliLoop runs one CLI variant every second until it first succeeds in the +// current boot. The pipe variant also runs before the socket exists; the pty +// variant starts once it does, because an interactive call waits through the +// load and its time to exit is the measurement. +func (s *startupProbe) cliLoop(ctx context.Context, cwd probeCWD, tty bool) { + done := -1 + s.every(ctx, func(ctx context.Context) { + boot, socketOn := s.bootState() + if boot < 0 || boot == done || (tty && !socketOn) { + return + } + sample := s.cliCall(ctx, cwd, tty) + if s.recordLive(ctx, sample) && sample.OK { + done = boot + } + }) +} + +func (s *startupProbe) beginBoot(index int) { + s.mu.Lock() + s.boot, s.socketOn = index, false + s.mu.Unlock() +} + +func (s *startupProbe) socketUp(index int) { + s.mu.Lock() + if s.boot == index { + s.socketOn = true + } + s.mu.Unlock() +} + +func (s *startupProbe) stopping() { + s.mu.Lock() + s.socketOn = false + s.mu.Unlock() +} + +func (s *startupProbe) bootState() (int, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.boot, s.socketOn +} + +func (s *startupProbe) daemonVersion() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.version +} + +// recordLive keeps a sample unless the probe was stopping when it ended: a +// call cut short by the end of the run is not an answer. +func (s *startupProbe) recordLive(ctx context.Context, sample probeSample) bool { + if ctx.Err() != nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + s.samples = append(s.samples, sample) + if s.logErr == nil { + s.logErr = s.encoder.Encode(sample) + } + return true +} + +func (s *startupProbe) finish() ([]probeSample, error) { + s.workers.Wait() + s.mu.Lock() + defer s.mu.Unlock() + return s.samples, errors.Join(s.logErr, s.log.Close()) +} + +// mcpTick opens fresh sessions from cwd, as a newly started agent would: the +// legacy surface for search_symbols and the index-health resource, and the +// compact surface for workspace(operation:"index"). Each session records the +// instructions variant its initialize returned. +func (s *startupProbe) mcpTick(ctx context.Context, cwd probeCWD) { + sessions := []struct { + tools, mode, initialize string + steps []request + }{ + {"core", "defer", "initialize", s.plan[1:3]}, + {"facade-v1", "hide", "initialize_facade", s.plan[3:]}, + } + for _, session := range sessions { + if !s.mcpSession(ctx, cwd, session.tools, session.mode, session.initialize, session.steps) { + return + } + } +} + +func (s *startupProbe) mcpSession(ctx context.Context, cwd probeCWD, tools, mode, initialize string, steps []request) bool { + started := time.Now() + dialCtx, cancel := context.WithTimeout(ctx, startupCallTimeout) + conn, reader, version, err := dialSocketSurface(dialCtx, s.socket, cwd.Dir, "mcp", tools, mode) + cancel() + if err != nil { + code := "handshake_error" + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + code = "no_socket" + } + s.recordLive(ctx, probeSample{CWD: cwd.Role, Surface: "connect", Started: started, LatencyMS: elapsedMS(started), Code: code, Text: clip(err.Error())}) + return false + } + defer conn.Close() + s.mu.Lock() + s.version = version + s.mu.Unlock() + c := &probeConn{conn: conn, reader: reader} + initParams := map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]string{"name": "gortex-load", "version": "1"}} + calls := []probeCall{{initialize, "initialize", initParams}} + for _, step := range steps { + switch { + case step.Tool == "resources/read": + calls = append(calls, probeCall{"index_health_resource", step.Tool, step.Arguments}) + case step.Operation != "": + calls = append(calls, probeCall{step.Tool + "_" + step.Operation, "tools/call", map[string]any{"name": step.Tool, "arguments": step.Arguments}}) + default: + calls = append(calls, probeCall{step.Tool, "tools/call", map[string]any{"name": step.Tool, "arguments": step.Arguments}}) + } + } + for i, call := range calls { + start := time.Now() + callCtx, cancel := context.WithTimeout(ctx, startupCallTimeout) + frame, err := c.roundTrip(callCtx, call.method, call.params) + timedOut := callCtx.Err() == context.DeadlineExceeded + cancel() + var sample probeSample + switch { + case err == nil: + sample = decodeProbeFrame(frame) + case timedOut: + sample = probeSample{Code: "timeout", Text: clip(err.Error())} + default: + sample = probeSample{Code: "transport_error", Text: clip(err.Error())} + } + sample.CWD, sample.Surface, sample.Started, sample.LatencyMS = cwd.Role, call.surface, start, elapsedMS(start) + if !s.recordLive(ctx, sample) || err != nil { + return ctx.Err() == nil + } + if i == 0 && c.notify("notifications/initialized") != nil { + return true + } + } + return true +} + +type probeCall struct { + surface, method string + params any +} + +type probeConn struct { + conn net.Conn + reader *bufio.Reader + next int +} + +// roundTrip sends one request and returns its response frame, skipping +// notifications and server-initiated requests. +func (c *probeConn) roundTrip(ctx context.Context, method string, params any) ([]byte, error) { + c.next++ + id := strconv.Itoa(c.next) + data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": c.next, "method": method, "params": params}) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok { + if err := c.conn.SetDeadline(deadline); err != nil { + return nil, err + } + } + stop := context.AfterFunc(ctx, func() { _ = c.conn.SetDeadline(time.Now()) }) + defer stop() + if _, err := c.conn.Write(append(data, '\n')); err != nil { + return nil, err + } + for { + line, err := readWireLine(c.reader) + if err != nil { + return nil, err + } + var peek struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(line, &peek); err != nil { + return nil, fmt.Errorf("invalid daemon JSON: %w", err) + } + if peek.Method == "" && string(peek.ID) == id { + return line, nil + } + } +} + +func (c *probeConn) notify(method string) error { + data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method}) + if err == nil { + _, err = c.conn.Write(append(data, '\n')) + } + return err +} + +// admissionFields are the machine-readable fields of a refusal's JSON-RPC +// error data, or of a tool's JSON payload. +type admissionFields struct { + ErrorCode string `json:"error_code"` + Retryable *bool `json:"retryable"` + Phase string `json:"phase"` + Percent *float64 `json:"percent"` + Status string `json:"status"` +} + +func (f admissionFields) apply(s *probeSample) { + if f.ErrorCode != "" { + s.Code = f.ErrorCode + } + s.Retryable, s.Phase, s.Status = f.Retryable, f.Phase, f.Status + if f.Percent != nil { + percent := int(*f.Percent) + s.Percent = &percent + } +} + +// decodeProbeFrame classifies one JSON-RPC response frame. +func decodeProbeFrame(frame []byte) probeSample { + var reply struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } `json:"error"` + Result *struct { + Instructions *string `json:"instructions"` + IsError bool `json:"isError"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + Contents []struct { + Text string `json:"text"` + } `json:"contents"` + } `json:"result"` + } + if err := json.Unmarshal(frame, &reply); err != nil || (reply.Error == nil && reply.Result == nil) { + return probeSample{Code: "invalid_response", Text: clip(string(frame))} + } + // Successful graph answers can quote indexed text that mentions the + // commands, so only refusals and instructions are checked for advice. + var sample probeSample + if reply.Error != nil { + sample.TrackAdvice = adviceIn(string(frame)) + sample.Code = fmt.Sprintf("rpc_%d", reply.Error.Code) + var fields admissionFields + _ = json.Unmarshal(reply.Error.Data, &fields) + fields.apply(&sample) + sample.Text = clip(reply.Error.Message) + return sample + } + result := reply.Result + if result.Instructions != nil { + sample.OK, sample.Variant, sample.TrackAdvice = true, instructionsVariant(*result.Instructions), adviceIn(*result.Instructions) + if sample.Variant != "active" { + sample.Text = clip(*result.Instructions) + } + return sample + } + text := "" + for _, block := range append(result.Content, result.Contents...) { + if text = block.Text; text != "" { + break + } + } + var fields admissionFields + _ = json.Unmarshal([]byte(text), &fields) + fields.apply(&sample) + sample.OK = !result.IsError && fields.ErrorCode == "" + if !sample.OK { + if sample.Code == "" { + sample.Code = "tool_error" + } + sample.Text, sample.TrackAdvice = clip(text), adviceIn(string(frame)) + } + return sample +} + +// instructionsVariant names the initialize instructions an agent received. +// Anything without one of the inactive or loading preambles is active. +func instructionsVariant(text string) string { + for _, variant := range []struct{ prefix, name string }{ + {"Gortex is LOADING graph admission", "admission_pending"}, + {"Gortex is LOADING ", "loading"}, + {"Gortex is connected but could not load", "unavailable"}, + {"Gortex is connected but INACTIVE", "untracked"}, + {"Gortex is connected; checkout discovery is pending", "checkout_pending"}, + {"Gortex checkout discovery is unavailable", "checkout_admission_error"}, + } { + if strings.HasPrefix(text, variant.prefix) { + return variant.name + } + } + if strings.TrimSpace(text) == "" { + return "empty" + } + return "active" +} + +// trackAdvicePattern finds advice to run `gortex track` or `gortex repos +// reconcile`. A negated mention ("Do not run `gortex track`") is guidance +// against it and is captured in the first group so it can be ignored. +var trackAdvicePattern = regexp.MustCompile("(?i)(\\bnot\\s+(?:run\\s+)?|n't\\s+(?:run\\s+)?|\\bnever\\s+(?:run\\s+)?)?`?gortex\\s+(?:track|repos\\s+reconcile)\\b") + +func adviceIn(text string) bool { + for _, match := range trackAdvicePattern.FindAllStringSubmatch(text, -1) { + if match[1] == "" { + return true + } + } + return false +} + +// cliCall runs `gortex call search_symbols` from cwd. Without a pty the CLI +// must fail fast with exit code 75 while the repository loads; with one it +// waits for the load, so its time to exit includes that wait. +func (s *startupProbe) cliCall(ctx context.Context, cwd probeCWD, tty bool) probeSample { + surface := "cli_pipe" + if tty { + surface = "cli_tty" + } + args, _ := json.Marshal(map[string]any{"query": s.query, "limit": 1}) + callCtx, cancel := context.WithTimeout(ctx, startupCLITimeout) + defer cancel() + argv := []string{"call", "search_symbols", "--json", string(args)} + var cmd *exec.Cmd + var err error + if tty { + cmd, err = ttyCommand(callCtx, s.binary, argv) + } else { + cmd = exec.CommandContext(callCtx, s.binary, argv...) + } + started := time.Now() + if err != nil { + return probeSample{CWD: cwd.Role, Surface: surface, Started: started, Code: "unavailable", Text: clip(err.Error())} + } + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + exitCode, err := runOwned(cmd, cwd.Dir, s.env) + sample := probeSample{CWD: cwd.Role, Surface: surface, Started: started, LatencyMS: elapsedMS(started), ExitCode: &exitCode} + text := output.String() + sample.OK, sample.Code = classifyCLI(exitCode, text) + if callCtx.Err() == context.DeadlineExceeded { + sample.OK, sample.Code = false, "timeout" + } else if err != nil && exitCode < 0 { + sample.Code = "exec_error" + text += err.Error() + } + if !sample.OK { + sample.Text, sample.TrackAdvice = clip(text), adviceIn(text) + } + return sample +} + +func classifyCLI(exitCode int, text string) (bool, string) { + switch { + case exitCode == 0: + return true, "" + case exitCode == 75: + return false, "repo_loading" + case strings.Contains(text, "does not track"): + return false, "repo_not_tracked" + case strings.Contains(text, "no gortex daemon is running"): + return false, "no_daemon" + default: + return false, fmt.Sprintf("exit_%d", exitCode) + } +} + +// ttyCommand runs argv under script(1) so the CLI's stdout is a terminal. +func ttyCommand(ctx context.Context, binary string, argv []string) (*exec.Cmd, error) { + switch runtime.GOOS { + case "darwin", "freebsd": + return exec.CommandContext(ctx, "script", append([]string{"-q", "/dev/null", binary}, argv...)...), nil + case "linux": + quoted := make([]string, 0, len(argv)+1) + for _, arg := range append([]string{binary}, argv...) { + quoted = append(quoted, "'"+strings.ReplaceAll(arg, "'", `'\''`)+"'") + } + return exec.CommandContext(ctx, "script", "-q", "-e", "-c", strings.Join(quoted, " "), "/dev/null"), nil + default: + return nil, fmt.Errorf("no pty runner on %s", runtime.GOOS) + } +} + +// hookRead asks the PreToolUse hook about a native Read of an indexed file. +func (s *startupProbe) hookRead(ctx context.Context, cwd probeCWD) probeSample { + payload, _ := json.Marshal(map[string]any{"hook_event_name": "PreToolUse", "session_id": "gortex-load-startup", "tool_name": "Read", "tool_input": map[string]any{"file_path": cwd.File}, "cwd": cwd.Dir}) + callCtx, cancel := context.WithTimeout(ctx, startupCallTimeout) + defer cancel() + cmd := exec.CommandContext(callCtx, s.binary, "hook") + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + started := time.Now() + exitCode, err := runOwned(cmd, cwd.Dir, s.env) + sample := probeSample{CWD: cwd.Role, Surface: "hook_read", Started: started, LatencyMS: elapsedMS(started), ExitCode: &exitCode} + decision, text := decodeHookOutput(stdout.Bytes()) + sample.Variant, sample.TrackAdvice, sample.Text = decision, adviceIn(text), clip(text) + sample.OK = err == nil + if err != nil { + sample.Variant, sample.Code = "", "hook_error" + if callCtx.Err() == context.DeadlineExceeded { + sample.Code = "timeout" + } + sample.Text = clip(err.Error() + " " + stderr.String()) + } + return sample +} + +// decodeHookOutput reads the default hook protocol's PreToolUse reply. No +// output, or context without a decision, lets the native tool run. +func decodeHookOutput(output []byte) (decision, text string) { + output = bytes.TrimSpace(output) + if len(output) == 0 { + return "allow", "" + } + var reply struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + Specific *struct { + Decision string `json:"permissionDecision"` + Reason string `json:"permissionDecisionReason"` + Context string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + } + if json.Unmarshal(output, &reply) != nil { + return "unparsed", string(output) + } + decision, text = "allow", reply.Reason + if reply.Specific != nil { + if reply.Specific.Decision != "" { + decision = reply.Specific.Decision + } + text = strings.TrimSpace(text + " " + reply.Specific.Reason + " " + reply.Specific.Context) + } + if reply.Decision == "block" { + decision = "deny" + } + return decision, text +} + +// runOwned runs a probe command in its own process group, so a call cut +// short by its deadline cannot leave children behind. +func runOwned(cmd *exec.Cmd, dir string, env []string) (int, error) { + configureOwnedProcess(cmd) + cmd.Dir, cmd.Env, cmd.WaitDelay = dir, env, 5*time.Second + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + return killOwnedProcess(cmd) + } + err := cmd.Run() + if err != nil && cmd.Process != nil { + _ = cleanupExitedOwnedProcess(cmd) + } + if cmd.ProcessState == nil { + return -1, err + } + return cmd.ProcessState.ExitCode(), err +} + +func elapsedMS(start time.Time) float64 { return durationMS(time.Since(start)) } + +func clip(text string) string { + text = strings.TrimSpace(text) + if len(text) <= startupTextLimit { + return text + } + return text[:startupTextLimit] + "..." +} diff --git a/bench/load/startup_probe_summary.go b/bench/load/startup_probe_summary.go new file mode 100644 index 000000000..495af080d --- /dev/null +++ b/bench/load/startup_probe_summary.go @@ -0,0 +1,247 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +// graphSurfaces answer from the repository graph. Their first success after +// the primary registers is how long an agent waits for a working tool. +var graphSurfaces = []string{"search_symbols", "index_health_resource", "cli_pipe", "cli_tty"} + +var probeRoles = []string{"primary", "worktree", "router"} + +type firstOKTiming struct { + FromStartMS Metric `json:"from_start_ms"` + FromReadyMS Metric `json:"from_ready_ms"` +} + +type bootSummary struct { + bootTiming + TimeToReadyMS Metric `json:"time_to_ready_ms"` + FirstOK map[string]firstOKTiming `json:"first_ok"` +} + +// startupSummary is the startup probe's gate receipt. Answers are keyed +// "surface/cwd" and split into windows by what the readiness watcher saw: +// no_socket before the socket answered, loading until the primary was ready, +// and ready after. +type startupSummary struct { + PrimaryRepo string `json:"primary_repo"` + LinkedWorktrees int `json:"linked_worktrees"` + WatchIntervalMS float64 `json:"watch_interval_ms"` + CLITTY string `json:"cli_tty"` + Boots []bootSummary `json:"boots"` + // Outcomes counts each answer as ok, ok:, an error code (with + // repo_loading(incomplete) for a loading refusal missing retryable=true, + // phase or percent), an initialize instructions variant, or a hook + // decision. + Outcomes map[string]map[string]map[string]int `json:"outcomes"` + TrackAdvice map[string]map[string]int `json:"track_advice"` + RestartReadyToFirstOK map[string]Distribution `json:"restart_ready_to_first_ok_ms"` + BetweenBoots int `json:"samples_between_boots"` +} + +// firstOK is when the earliest successful sample for surface and cwd that +// started in [from, until) completed; a zero until is open. Measured from a +// boot's start it is time to first OK; measured from readiness it is ready +// to first OK, for any warm or cold time-to-ready comparison. +func firstOK(samples []probeSample, surface, cwd string, from, until time.Time) (time.Time, bool) { + var first time.Time + for _, s := range samples { + if !s.OK || s.Surface != surface || s.CWD != cwd || s.Started.Before(from) || (!until.IsZero() && !s.Started.Before(until)) { + continue + } + if done := s.completed(); first.IsZero() || done.Before(first) { + first = done + } + } + return first, !first.IsZero() +} + +// bootIndex places a sample in the boot it observed. The probe starts just +// before the first daemon does, so earlier samples belong to the first boot; +// samples between a stop request and the next start belong to none. +func bootIndex(boots []bootTiming, at time.Time) int { + for i, boot := range boots { + if (i == 0 || !at.Before(boot.Started)) && (boot.Stopping.IsZero() || at.Before(boot.Stopping)) { + return i + } + } + return -1 +} + +func startupWindow(boot bootTiming, primary string, at time.Time) string { + if boot.SocketAt.IsZero() || at.Before(boot.SocketAt) { + return "no_socket" + } + if ready, ok := boot.ReadyAt[primary]; !ok || at.Before(ready) { + return "loading" + } + return "ready" +} + +func outcomeKey(s probeSample) string { + switch { + case s.Variant != "": + return s.Variant + case s.OK && s.Status != "": + return "ok:" + s.Status + case s.OK: + return "ok" + case s.Code == "repo_loading" && !strings.HasPrefix(s.Surface, "cli_") && (s.Retryable == nil || !*s.Retryable || s.Phase == "" || s.Percent == nil): + return "repo_loading(incomplete)" + default: + return s.Code + } +} + +func summarizeStartup(samples []probeSample, boots []bootTiming, primary string, linked int) startupSummary { + sum := startupSummary{PrimaryRepo: primary, LinkedWorktrees: linked, WatchIntervalMS: durationMS(startupWatchInterval), Outcomes: make(map[string]map[string]map[string]int), TrackAdvice: make(map[string]map[string]int), RestartReadyToFirstOK: make(map[string]Distribution)} + for _, s := range samples { + index := bootIndex(boots, s.Started) + if index < 0 { + sum.BetweenBoots++ + continue + } + key, window := s.Surface+"/"+s.CWD, startupWindow(boots[index], primary, s.Started) + if sum.Outcomes[key] == nil { + sum.Outcomes[key] = make(map[string]map[string]int) + } + if sum.Outcomes[key][window] == nil { + sum.Outcomes[key][window] = make(map[string]int) + } + sum.Outcomes[key][window][outcomeKey(s)]++ + if s.TrackAdvice { + if sum.TrackAdvice[key] == nil { + sum.TrackAdvice[key] = make(map[string]int) + } + sum.TrackAdvice[key][window]++ + } + } + restarts := make(map[string][]float64) + for i, boot := range boots { + row := bootSummary{bootTiming: boot, TimeToReadyMS: unavailable("ms", "primary repository never ready"), FirstOK: make(map[string]firstOKTiming)} + ready, registered := boot.ReadyAt[primary] + if registered { + row.TimeToReadyMS = measured(durationMS(ready.Sub(boot.Started)), "ms") + } + for _, surface := range graphSurfaces { + for _, cwd := range probeRoles { + first, ok := firstOK(samples, surface, cwd, boot.Started, boot.Stopping) + if !ok { + continue + } + key := surface + "/" + cwd + timing := firstOKTiming{FromStartMS: measured(durationMS(first.Sub(boot.Started)), "ms"), FromReadyMS: unavailable("ms", "primary repository never ready")} + if registered { + // The watcher stamps readiness up to one poll late, so a + // success can precede it; that wait is zero. + fromReady := max(0, durationMS(first.Sub(ready))) + timing.FromReadyMS = measured(fromReady, "ms") + if i > 0 { + restarts[key] = append(restarts[key], fromReady) + } + } + row.FirstOK[key] = timing + } + } + sum.Boots = append(sum.Boots, row) + } + for key, values := range restarts { + sum.RestartReadyToFirstOK[key] = distribution(values, "ms") + } + return sum +} + +// metrics are the headline counters. Counts cover every boot; restart +// figures cover boots after the first and are unavailable unless every +// restart produced the observation. +func (sum startupSummary) metrics() map[string]Metric { + count := func(match func(surface, window, outcome string) bool) Metric { + total := 0 + for key, windows := range sum.Outcomes { + surface, _, _ := strings.Cut(key, "/") + for window, outcomes := range windows { + for outcome, n := range outcomes { + if match(surface, window, outcome) { + total += n + } + } + } + } + return measured(float64(total), "count") + } + advice := func(window string) Metric { + total := 0 + for _, windows := range sum.TrackAdvice { + total += windows[window] + } + return measured(float64(total), "count") + } + m := map[string]Metric{ + "startup_boots": measured(float64(len(sum.Boots)), "count"), + "startup_linked_worktrees": measured(float64(sum.LinkedWorktrees), "count"), + // The untracked initialize variant is the same answer at handshake time. + "startup_repo_not_tracked_before_ready": count(func(_, window, outcome string) bool { + return window != "ready" && (outcome == "repo_not_tracked" || outcome == "untracked") + }), + "startup_nonconforming_loading_refusals": count(func(surface, window, outcome string) bool { + return window == "loading" && surface != "hook_read" && !strings.HasPrefix(surface, "initialize") && outcome != "repo_loading" && !strings.HasPrefix(outcome, "ok") + }), + "startup_hook_denies_while_loading": count(func(surface, window, outcome string) bool { + return surface == "hook_read" && window != "ready" && outcome == "deny" + }), + "startup_hook_denies_after_ready": count(func(surface, window, outcome string) bool { + return surface == "hook_read" && window == "ready" && outcome == "deny" + }), + "startup_track_advice_while_loading": advice("loading"), + "startup_track_advice_without_socket": advice("no_socket"), + "startup_initial_time_to_ready_ms": unavailable("ms", "no boot"), + "startup_initial_first_ok_ms": unavailable("ms", "no successful search_symbols from the primary"), + } + if len(sum.Boots) > 0 { + m["startup_initial_time_to_ready_ms"] = sum.Boots[0].TimeToReadyMS + if first, ok := sum.Boots[0].FirstOK["search_symbols/primary"]; ok { + m["startup_initial_first_ok_ms"] = first.FromStartMS + } + } + restarts := max(0, len(sum.Boots)-1) + var toReady []float64 + for _, boot := range sum.Boots[min(1, len(sum.Boots)):] { + if boot.TimeToReadyMS.Value != nil { + toReady = append(toReady, *boot.TimeToReadyMS.Value) + } + } + m["startup_restart_time_to_ready_p95_ms"] = restartP95(distribution(toReady, "ms"), restarts, "reached ready") + m["startup_restart_ready_to_first_ok_p95_ms"] = restartP95(sum.RestartReadyToFirstOK["search_symbols/primary"], restarts, "had a successful search_symbols from the primary") + // The census is per boot; with no restart the initial boot is all there is. + census := sum.Boots[min(1, len(sum.Boots)):] + if len(sum.Boots) == 1 { + census = sum.Boots + } + m["startup_coordinators_within_2s_min"] = unavailable("count", "no boot") + for i, boot := range census { + if boot.Coordinators == nil { + m["startup_coordinators_within_2s_min"] = unavailable("count", fmt.Sprintf("boot %d has no coordinator census", boot.Boot)) + break + } + if value := float64(*boot.Coordinators); i == 0 || value < *m["startup_coordinators_within_2s_min"].Value { + m["startup_coordinators_within_2s_min"] = measured(value, "count") + } + } + return m +} + +func restartP95(d Distribution, restarts int, observation string) Metric { + if restarts == 0 { + return unavailable("ms", "no restarts") + } + if d.Count < restarts { + return unavailable("ms", fmt.Sprintf("%d of %d restarts %s", d.Count, restarts, observation)) + } + return d.P95 +} + +func durationMS(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) } diff --git a/bench/load/startup_probe_test.go b/bench/load/startup_probe_test.go new file mode 100644 index 000000000..dd55103ce --- /dev/null +++ b/bench/load/startup_probe_test.go @@ -0,0 +1,329 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDecodeProbeFrameReadsAdmissionAnswers(t *testing.T) { + active := "When Gortex can serve the step: MUST use Gortex MCP.\n\nWorktree and branch routing:\n\n- A linked worktree is an automatic overlay; do not `track` it. Graph calls return `view_building` until discovery completes; retry them, no reconnect is needed. Do not run `gortex track`." + for _, test := range []struct { + name, frame, outcome string + ok, advice bool + check func(t *testing.T, s probeSample) + }{ + {name: "loading refusal", outcome: "repo_loading", + frame: `{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"repository loading: app (resolving, 40%)","data":{"error_code":"repo_loading","retryable":true,"phase":"resolving","percent":40,"suggestion":"Gortex is still loading app after a restart; tools start working in place, no reconnect needed. Retry shortly. Do not change tracking configuration."}}}`, + check: func(t *testing.T, s probeSample) { + if s.Retryable == nil || !*s.Retryable || s.Phase != "resolving" || s.Percent == nil || *s.Percent != 40 { + t.Fatalf("loading fields lost: %+v", s) + } + }}, + {name: "loading refusal without progress", outcome: "repo_loading(incomplete)", + frame: `{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"repository loading","data":{"error_code":"repo_loading","retryable":true}}}`}, + {name: "untracked refusal advises tracking", outcome: "repo_not_tracked", advice: true, + frame: "{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32000,\"message\":\"repository not tracked: /w\",\"data\":{\"error_code\":\"repo_not_tracked\",\"path\":\"/w\",\"suggestion\":\"Run `gortex track /w` to include this repo in the shared graph.\"}}}"}, + {name: "unavailable is final", outcome: "repo_unavailable", advice: true, + frame: "{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32000,\"message\":\"repository unavailable\",\"data\":{\"error_code\":\"repo_unavailable\",\"retryable\":false,\"suggestion\":\"Gortex could not load /w after the restart. Run `gortex track /w` to index it again.\"}}}", + check: func(t *testing.T, s probeSample) { + if s.Retryable == nil || *s.Retryable { + t.Fatalf("retryable=false must be kept: %+v", s) + } + }}, + {name: "busy without data", outcome: "rpc_-32002", + frame: `{"jsonrpc":"2.0","id":2,"error":{"code":-32002,"message":"busy"}}`}, + {name: "loading index health answers", outcome: "ok:loading", ok: true, + frame: `{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"status\":\"loading\",\"phase\":\"starting\",\"percent\":0,\"repos\":[]}"}]}}`, + check: func(t *testing.T, s probeSample) { + if s.Phase != "starting" || s.Percent == nil || *s.Percent != 0 { + t.Fatalf("a zero percent is an observation, not a missing one: %+v", s) + } + }}, + {name: "resource read", outcome: "ok:ok", ok: true, + frame: `{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"gortex://index-health","text":"{\"status\":\"ok\"}"}]}}`}, + {name: "search results may quote the commands", outcome: "ok", ok: true, + frame: "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"results\\\":[{\\\"doc\\\":\\\"run `gortex track .` first\\\"}]}\"}]}}"}, + {name: "tool error payload", outcome: "view_building", + frame: `{"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[{"type":"text","text":"{\"error_code\":\"view_building\",\"message\":\"retry\"}"}]}}`}, + {name: "plain tool error", outcome: "tool_error", + frame: `{"jsonrpc":"2.0","id":2,"result":{"isError":true,"content":[{"type":"text","text":"tool not found"}]}}`}, + {name: "loading instructions", outcome: "loading", ok: true, + frame: mustInitialize(t, "Gortex is LOADING app after a restart (starting, 0%). Graph tools return repo_loading (retryable) until it is ready; Do not re-track it or change tracking configuration: the repository is already configured.\n\n"+active)}, + {name: "admission pending instructions", outcome: "admission_pending", ok: true, + frame: mustInitialize(t, "Gortex is LOADING graph admission after a restart (identity_resolution, 0%). Graph admission for this path is not yet established.")}, + {name: "unavailable instructions", outcome: "unavailable", ok: true, advice: true, + frame: mustInitialize(t, "Gortex is connected but could not load /w after the restart: boom.\n\nGraph tools return repo_unavailable for it. To repair, run `gortex track /w` to index it again.")}, + {name: "untracked instructions", outcome: "untracked", ok: true, advice: true, + frame: mustInitialize(t, "Gortex is connected but INACTIVE for this directory: \"/w\" is not covered by any tracked repository.\n\nTo activate: run `gortex track /w`, then retry graph calls; no reconnect is needed.")}, + {name: "active instructions forbid tracking", outcome: "active", ok: true, + frame: mustInitialize(t, active)}, + {name: "not JSON", outcome: "invalid_response", frame: `daemon: serving`}, + } { + t.Run(test.name, func(t *testing.T) { + s := decodeProbeFrame([]byte(test.frame)) + if got := outcomeKey(s); got != test.outcome || s.OK != test.ok || s.TrackAdvice != test.advice { + t.Fatalf("outcome %q ok=%v advice=%v, want %q ok=%v advice=%v: %+v", got, s.OK, s.TrackAdvice, test.outcome, test.ok, test.advice, s) + } + if test.check != nil { + test.check(t, s) + } + }) + } +} + +func mustInitialize(t *testing.T, instructions string) string { + t.Helper() + data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"protocolVersion": "2024-11-05", "instructions": instructions}}) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestTrackAdviceIgnoresGuidanceAgainstIt(t *testing.T) { + for text, want := range map[string]bool{ + "Run `gortex track /w` to include this repo": true, + "the gortex daemon does not track /w — add it with `gortex track /w`": true, + "run `gortex repos reconcile /w` and retry": true, + "start it, then track this repo with gortex track /w": true, + "Do not run `gortex track`.": false, + "Don't run gortex track here": false, + "never gortex repos reconcile a loading family": false, + "remove it with `gortex untrack /w`": false, + "gortex tracks hundreds of repositories": false, + "the repository is already tracked; retry shortly": false, + "Do not run `gortex track`. To activate: run `gortex track /w`.": true, + } { + if got := adviceIn(text); got != want { + t.Errorf("adviceIn(%q) = %v, want %v", text, got, want) + } + } +} + +func TestCLIAndHookAnswers(t *testing.T) { + for _, test := range []struct { + exit int + text string + ok bool + code string + }{ + {0, `{"results":[]}`, true, ""}, + {75, "the gortex daemon is still loading app after a restart (starting, 0%)", false, "repo_loading"}, + {1, "the gortex daemon does not track /w — add it with `gortex track /w`", false, "repo_not_tracked"}, + {1, "no gortex daemon is running — start it with `gortex daemon start --detach`", false, "no_daemon"}, + {2, "unknown flag", false, "exit_2"}, + } { + if ok, code := classifyCLI(test.exit, test.text); ok != test.ok || code != test.code { + t.Errorf("classifyCLI(%d, %q) = %v %q, want %v %q", test.exit, test.text, ok, code, test.ok, test.code) + } + } + for output, want := range map[string]string{ + "": "allow", + `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"use read(operation:\"file\")"}}`: "deny", + `{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"app is still loading (repo_loading); the native Read is allowed"}}`: "allow", + `{"decision":"block","reason":"indexed"}`: "deny", + `not json`: "unparsed", + } { + if got, _ := decodeHookOutput([]byte(output)); got != want { + t.Errorf("decodeHookOutput(%q) = %q, want %q", output, got, want) + } + } +} + +// roundTrip must skip pushes the daemon interleaves before the response. +func TestProbeRoundTripSkipsNotifications(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + go func() { + defer server.Close() + request, err := bufio.NewReader(server).ReadString('\n') + if err != nil || !strings.Contains(request, `"method":"tools/call"`) { + return + } + _, _ = server.Write([]byte(`{"jsonrpc":"2.0","method":"notifications/message","params":{"data":"loading"}}` + "\n" + + `{"jsonrpc":"2.0","id":7,"method":"roots/list"}` + "\n" + + `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{}"}]}}` + "\n")) + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c := &probeConn{conn: client, reader: bufio.NewReader(client)} + frame, err := c.roundTrip(ctx, "tools/call", map[string]any{"name": "search_symbols"}) + if err != nil || !strings.Contains(string(frame), `"id":1`) { + t.Fatalf("roundTrip = %s, %v", frame, err) + } +} + +func TestFirstOKUsesEarliestCompletionInsideTheBoot(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + samples := []probeSample{ + {CWD: "primary", Surface: "search_symbols", Started: t0, OK: true}, // previous boot + {CWD: "primary", Surface: "search_symbols", Started: t0.Add(11 * time.Second), LatencyMS: 3000, OK: true}, // slow + {CWD: "primary", Surface: "search_symbols", Started: t0.Add(12 * time.Second), LatencyMS: 100, OK: true}, // first to finish + {CWD: "worktree", Surface: "search_symbols", Started: t0.Add(11 * time.Second), LatencyMS: 10, OK: true}, // other cwd + {CWD: "primary", Surface: "search_symbols", Started: t0.Add(10500 * time.Millisecond), LatencyMS: 5, Code: "repo_loading"}, + {CWD: "primary", Surface: "search_symbols", Started: t0.Add(30 * time.Second), LatencyMS: 1, OK: true}, // next boot + } + first, ok := firstOK(samples, "search_symbols", "primary", t0.Add(10*time.Second), t0.Add(20*time.Second)) + if want := t0.Add(12100 * time.Millisecond); !ok || !first.Equal(want) { + t.Fatalf("firstOK = %v %v, want %v", first, ok, want) + } + if _, ok := firstOK(samples, "cli_pipe", "primary", t0, time.Time{}); ok { + t.Fatal("no sample of the surface is not a first OK") + } +} + +func TestSummarizeStartupCountsPerWindowAndRestart(t *testing.T) { + t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + at := func(ms int) time.Time { return t0.Add(time.Duration(ms) * time.Millisecond) } + coordinators := func(n int) *int { return &n } + boots := []bootTiming{ + {Boot: 0, Started: at(0), SocketAt: at(2000), ReadyAt: map[string]time.Time{"app": at(5000)}, Stopping: at(20000), Coordinators: coordinators(3)}, + {Boot: 1, Started: at(21000), SocketAt: at(21500), ReadyAt: map[string]time.Time{"app": at(22000)}, Stopping: at(30000), Coordinators: coordinators(4)}, + {Boot: 2, Started: at(31000), SocketAt: at(31200), ReadyAt: map[string]time.Time{"app": at(31600)}, Coordinators: coordinators(2)}, + } + yes, percent := true, 10 + samples := []probeSample{ + {CWD: "primary", Surface: "connect", Started: at(-500), Code: "no_socket"}, // before the first start + {CWD: "primary", Surface: "cli_pipe", Started: at(1000), Code: "no_daemon", TrackAdvice: true}, + {CWD: "primary", Surface: "initialize", Started: at(3000), OK: true, Variant: "loading"}, + {CWD: "router", Surface: "initialize", Started: at(3000), OK: true, Variant: "untracked", TrackAdvice: true}, + {CWD: "primary", Surface: "search_symbols", Started: at(3000), Code: "repo_loading", Retryable: &yes, Phase: "starting", Percent: &percent}, + {CWD: "primary", Surface: "search_symbols", Started: at(4000), Code: "repo_loading", Retryable: &yes}, + {CWD: "worktree", Surface: "search_symbols", Started: at(3000), Code: "repo_not_tracked", TrackAdvice: true}, + {CWD: "primary", Surface: "workspace_index", Started: at(3000), OK: true, Status: "loading"}, + {CWD: "primary", Surface: "hook_read", Started: at(3000), OK: true, Variant: "allow"}, + {CWD: "primary", Surface: "hook_read", Started: at(6000), OK: true, Variant: "deny"}, + {CWD: "primary", Surface: "search_symbols", Started: at(5500), LatencyMS: 100, OK: true}, + {CWD: "primary", Surface: "search_symbols", Started: at(20500), Code: "no_socket"}, // between boots + {CWD: "primary", Surface: "search_symbols", Started: at(23000), LatencyMS: 50, OK: true}, + {CWD: "primary", Surface: "search_symbols", Started: at(31500), LatencyMS: 50, OK: true}, // before the stamp + } + sum := summarizeStartup(samples, boots, "app", 24) + if got := sum.Outcomes["search_symbols/primary"]["loading"]; got["repo_loading"] != 1 || got["repo_loading(incomplete)"] != 1 || got["ok"] != 1 { + t.Fatalf("loading outcomes = %v", got) + } + if got := sum.Outcomes["search_symbols/primary"]["ready"]["ok"]; got != 2 { + t.Fatalf("ready successes = %d", got) + } + if sum.Outcomes["connect/primary"]["no_socket"]["no_socket"] != 1 || sum.BetweenBoots != 1 || sum.TrackAdvice["cli_pipe/primary"]["no_socket"] != 1 || sum.TrackAdvice["search_symbols/worktree"]["loading"] != 1 { + t.Fatalf("between=%d advice=%v", sum.BetweenBoots, sum.TrackAdvice) + } + if first := sum.Boots[0].FirstOK["search_symbols/primary"]; *first.FromStartMS.Value != 5600 || *first.FromReadyMS.Value != 600 { + t.Fatalf("initial first OK = %+v", first) + } + if d := sum.RestartReadyToFirstOK["search_symbols/primary"]; d.Count != 2 || *d.P95.Value != 1050 || *d.P50.Value != 0 { + t.Fatalf("restart ready to first OK = %+v", d) + } + metrics := sum.metrics() + for name, want := range map[string]float64{ + "startup_boots": 3, + "startup_linked_worktrees": 24, + "startup_repo_not_tracked_before_ready": 2, + "startup_nonconforming_loading_refusals": 2, + "startup_hook_denies_while_loading": 0, + "startup_hook_denies_after_ready": 1, + "startup_track_advice_while_loading": 2, + "startup_track_advice_without_socket": 1, + "startup_initial_time_to_ready_ms": 5000, + "startup_initial_first_ok_ms": 5600, + "startup_restart_time_to_ready_p95_ms": 1000, + "startup_restart_ready_to_first_ok_p95_ms": 1050, + "startup_coordinators_within_2s_min": 2, + } { + if m := metrics[name]; m.Value == nil || *m.Value != want { + t.Errorf("%s = %+v, want %v", name, m, want) + } + } + + // A restart that never answered must not vanish from the p95. + sum = summarizeStartup(samples[:len(samples)-1], boots, "app", 24) + if m := sum.metrics()["startup_restart_ready_to_first_ok_p95_ms"]; m.Value != nil || !strings.Contains(m.Reason, "1 of 2 restarts") { + t.Fatalf("missing restart observation = %+v", m) + } + boots[2].Coordinators = nil + if m := summarizeStartup(samples, boots, "app", 24).metrics()["startup_coordinators_within_2s_min"]; m.Value != nil { + t.Fatalf("a boot without a census cannot report a minimum: %+v", m) + } +} + +func TestStartupOptionsStayScoped(t *testing.T) { + base := []string{"--binary", "/b", "--manifest", "/m", "--scratch", "/s"} + o, err := parseRun(append(base, "--scenario", "startup", "--restarts", "10", "--seed-store", "/v21.db")) + if err != nil || o.Startup.Restarts != 10 || o.Startup.SeedStore != "/v21.db" { + t.Fatalf("startup options = %+v, %v", o.Startup, err) + } + if scenarios, _ := selectedScenarios(o); len(scenarios) != 1 || scenarios[0] != "startup" { + t.Fatalf("startup scenarios = %v", scenarios) + } + o, err = parseRun(append(base, "--scenario", "all")) + if scenarios, _ := selectedScenarios(o); err != nil || strings.Contains(strings.Join(scenarios, ","), "startup") { + t.Fatalf("all must not restart daemons on its own: %v %v", scenarios, err) + } + for _, args := range [][]string{{"--scenario", "read", "--restarts", "1"}, {"--scenario", "cold", "--seed-store", "/v21.db"}, {"--scenario", "startup", "--restarts", "-1"}} { + if _, err := parseRun(append(base, args...)); err == nil { + t.Errorf("parseRun(%v) accepted startup options outside their scope", args) + } + } +} + +// The whole scenario against the fixture daemon: probing starts before the +// first socket, keeps sampling through a restart, and the gate, metrics and +// per-second log come from that one run. +func TestStartupScenarioProbesAcrossARestart(t *testing.T) { + t.Setenv("LOAD_DAEMON_FIXTURE", "serve") + corpus := gitFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + options := runOptions{Binary: binary, Profile: "agent", Host: "native", Agents: 1, Worktrees: 1, Duration: time.Second, Deadline: 2 * time.Second, WarmTimeout: 20 * time.Second, StopTimeout: 5 * time.Second, KeepState: true, Startup: startupOptions{Restarts: 1}} + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + phase, _, artifacts, crashes, version, err := runPhase(ctx, options, startupScenario, []Corpus{corpus}, "", t.TempDir()) + if err != nil { + t.Fatalf("startup scenario: %v %+v", err, phase.Errors) + } + var summary startupSummary + if err := json.Unmarshal(phase.Gate["startup_probe"], &summary); err != nil { + t.Fatal(err) + } + if version != "fixture-v1" || len(summary.Boots) != 2 || len(phase.Status) != 1 || len(phase.Stats) != 1 || crashes["panic_lines"] != 0 { + t.Fatalf("version=%q boots=%d status=%d stats=%d crashes=%v", version, len(summary.Boots), len(phase.Status), len(phase.Stats), crashes) + } + for _, key := range []string{"initialize/primary", "initialize_facade/worktree", "search_symbols/primary", "search_symbols/worktree", "search_symbols/router", "index_health_resource/router", "workspace_index/primary", "hook_read/worktree", "cli_pipe/primary"} { + answered := 0 + for _, outcomes := range summary.Outcomes[key] { + answered += outcomes["ok"] + outcomes["allow"] + } + if answered == 0 { + t.Errorf("%s has no successful answer: %v", key, summary.Outcomes[key]) + } + } + for _, name := range []string{"startup_initial_first_ok_ms", "startup_restart_ready_to_first_ok_p95_ms", "startup_restart_time_to_ready_p95_ms"} { + if phase.Metrics[name].Value == nil { + t.Errorf("%s unavailable: %s", name, phase.Metrics[name].Reason) + } + } + if m := phase.Metrics["startup_coordinators_within_2s_min"]; m.Value == nil || *m.Value != 1 || *phase.Metrics["startup_linked_worktrees"].Value != 1 { + t.Errorf("coordinator census = %+v", m) + } + log, err := os.ReadFile(artifacts["startup_probe_log"]) + if err != nil || strings.Count(string(log), "\n") < 10 { + t.Fatalf("per-second log: %d bytes, %v", len(log), err) + } + roster, err := os.ReadFile(filepath.Join(artifacts["private_root"], "config", "gortex", "servers.toml")) + if err != nil || !strings.Contains(string(roster), startupRosterWorkspace) { + t.Fatalf("router roster: %s %v", roster, err) + } + claim, err := os.ReadFile(filepath.Join(artifacts["private_root"], "repos", corpus.Label, ".gortex-probe-router", ".gortex.yaml")) + if err != nil || string(claim) != "workspace: "+startupRosterWorkspace+"\n" { + t.Fatalf("router cwd claim: %q %v", claim, err) + } +} diff --git a/bench/load/validate.go b/bench/load/validate.go index 4f5316eef..41f148d9f 100644 --- a/bench/load/validate.go +++ b/bench/load/validate.go @@ -119,7 +119,7 @@ func validateReport(data []byte, r Report) error { if len(phase.Operations) == 0 || len(phase.Gate) == 0 || len(phase.Status) == 0 || len(phase.Stats) == 0 { return errors.New("complete phase is missing plan/gate/status/stats observations") } - if phase.Name != "cold" && phase.Name != "restart" && len(phase.Tools) == 0 { + if phase.Name != "cold" && phase.Name != "restart" && phase.Name != startupScenario && len(phase.Tools) == 0 { return errors.New("complete workload phase has no tool observations") } }