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") } } diff --git a/cmd/gortex/cli_daemon.go b/cmd/gortex/cli_daemon.go index 018d601b7..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} + 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_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") +} diff --git a/cmd/gortex/cli_repo_loading.go b/cmd/gortex/cli_repo_loading.go index d2b24a958..50a82b0fa 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 + admissionReason string + 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.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)) + } + 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.go b/cmd/gortex/daemon.go index bcaffec35..68cc0a694 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -652,7 +652,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_controller_view.go b/cmd/gortex/daemon_controller_view.go index 5d38ba073..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,8 +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 + 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 @@ -414,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 } @@ -422,13 +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 + 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_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_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) diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index 7e5c20e90..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 @@ -450,7 +457,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 +475,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 @@ -499,18 +500,61 @@ 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 { + 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 + } + } + 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 { + 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 8bd3cc143..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) @@ -53,6 +61,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) @@ -104,6 +115,17 @@ 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 { + 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": reason, "reason": reason, "phase": phase, "percent": 0, + "suggestion": suggestion, + }) + } phase, percent := "starting", 0 if d.srv != nil { phase, percent = d.srv.WarmupProgress() @@ -146,17 +168,20 @@ func (d *mcpDispatcher) loadingIndexHealth(inbound []byte, rec indexer.RepoLoad) 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" + 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()) + } 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_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/cmd/gortex/daemon_repo_identity_pending_test.go b/cmd/gortex/daemon_repo_identity_pending_test.go new file mode 100644 index 000000000..ad6913037 --- /dev/null +++ b/cmd/gortex/daemon_repo_identity_pending_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "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","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") + 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} + 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()) + 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) + 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","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) + 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) { + 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" { + 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/cmd/gortex/daemon_repo_loading.go b/cmd/gortex/daemon_repo_loading.go index 0159b3d41..4e3074229 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()) } } @@ -60,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 - } - rec, pending := c.multiIndexer.PendingRepoForPath(abs) - if !pending || !rec.State.Loading() { - return "", false + return indexer.RepoLoad{}, 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..e34f14ff7 --- /dev/null +++ b/cmd/gortex/daemon_repo_publication_test.go @@ -0,0 +1,146 @@ +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, "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) { + _, 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") +} diff --git a/cmd/gortex/daemon_startup_loading_test.go b/cmd/gortex/daemon_startup_loading_test.go new file mode 100644 index 000000000..64c12ee46 --- /dev/null +++ b/cmd/gortex/daemon_startup_loading_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "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 +// 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"}}}`))) + 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)) + 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) + 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"]) + 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") + 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()} + 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"}}}`))) +} 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/ensure_daemon.go b/cmd/gortex/ensure_daemon.go index eb5af8f47..a62dc011e 100644 --- a/cmd/gortex/ensure_daemon.go +++ b/cmd/gortex/ensure_daemon.go @@ -24,16 +24,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..3277f50a1 --- /dev/null +++ b/cmd/gortex/migration_window_proxy_test.go @@ -0,0 +1,348 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "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"` +} + +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 +// 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 + 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() + 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) + // 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) + } + 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) + 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() }) + _, 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 + 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() + 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") +} 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()} } diff --git a/internal/daemon/proto.go b/internal/daemon/proto.go index d80f1ea03..efa12472f 100644 --- a/internal/daemon/proto.go +++ b/internal/daemon/proto.go @@ -323,6 +323,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. @@ -1024,6 +1027,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/graph/graph.go b/internal/graph/graph.go index 350a0d999..7fe222b1b 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/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 665c2dee5..987760724 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] 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) @@ -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 @@ -1640,7 +1640,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/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/edit_delta_capability_test.go b/internal/indexer/edit_delta_capability_test.go index 390e19b6d..458867887 100644 --- a/internal/indexer/edit_delta_capability_test.go +++ b/internal/indexer/edit_delta_capability_test.go @@ -100,7 +100,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", diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index f2507de14..ab18f8083 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 @@ -2073,79 +2063,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 { @@ -2291,370 +2208,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() @@ -2731,8 +2284,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/repo_load_identity_test.go b/internal/indexer/repo_load_identity_test.go new file mode 100644 index 000000000..84cfbe1a9 --- /dev/null +++ b/internal/indexer/repo_load_identity_test.go @@ -0,0 +1,221 @@ +package indexer + +import ( + "os" + "path/filepath" + "runtime" + "syscall" + "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) { + 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" { + 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") +} + +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()) + }) + } +} + +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_publication_test.go b/internal/indexer/repo_load_publication_test.go new file mode 100644 index 000000000..78a51a974 --- /dev/null +++ b/internal/indexer/repo_load_publication_test.go @@ -0,0 +1,66 @@ +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") + // 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 080b705d4..a38845d69 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" @@ -27,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 @@ -43,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. @@ -73,10 +77,15 @@ 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 + identityToken *byte } // Label names the repository in user-facing text: its prefix, or its root @@ -104,13 +113,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 } @@ -156,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. @@ -183,14 +195,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 } @@ -202,32 +227,125 @@ func (mi *MultiIndexer) ExpectRepos(entries []config.RepoEntry) { var added []RepoLoad 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 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, 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 { + // 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 + } + mi.publishRepoLoadIdentity(rec, resolved) + } +} + +func resolveRepoLoadIdentity(rec RepoLoad) (RepoLoad, error) { + 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 + } + 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 strings.HasPrefix(rec.Reason, "identity resolution pending: ") { + rec.Reason = "" + } + 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. @@ -348,20 +466,29 @@ 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) { 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 - records[i].commonDir = "" + if registered.canonicalKey != "" { + records[i].canonicalKey = registered.canonicalKey + } + records[i].commonDir = registered.commonDir + records[i].identityPending = registered.identityPending + records[i].identityToken = registered.identityToken + if registered.identityPending { + records[i].Reason = registered.Reason + } 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 { @@ -435,35 +562,75 @@ 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, 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) if commonDir == "" { return RepoLoad{}, false } + var ready RepoLoad + hasReady := false for _, rec := range snap.records { - if rec.State.Loading() && 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. @@ -480,21 +647,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 } } @@ -506,9 +674,16 @@ 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) - if !info.IsWorktree || info.GitCommonDir == "" { + _, 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 "" } // Git writes a worktree's gitdir with symlinks resolved while a diff --git a/internal/indexer/repo_load_state_test.go b/internal/indexer/repo_load_state_test.go index cdcc8ee84..e6c146aca 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" @@ -250,3 +251,153 @@ 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") } + +// 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") + 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) + _, 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) + 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) + 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") +} + +// 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): + } +} 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]))) diff --git a/internal/indexer/worktree.go b/internal/indexer/worktree.go index cef1f9641..596f204d7 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,35 +61,53 @@ 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) } + 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")) 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 +119,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 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/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/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/diff_repo_scope_test.go b/internal/mcp/diff_repo_scope_test.go index 213da63c0..4036d9199 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) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, diff --git a/internal/mcp/ensure_fresh_self_heal_test.go b/internal/mcp/ensure_fresh_self_heal_test.go index 193cf393a..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" @@ -44,8 +45,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_extractor_lang_test.go b/internal/mcp/freshness_rider_extractor_lang_test.go index d5bd0bb66..ab7c8c735 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) + 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/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/graph_stats_per_repo_test.go b/internal/mcp/graph_stats_per_repo_test.go index 6401705ef..8690161bd 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) + 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..090ace518 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) + 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..3447eea77 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) + require.NoError(t, err) + } srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{ ConfigManager: cm, 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 c77a59582..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" @@ -151,8 +152,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/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/repo_identity_pending_test.go b/internal/mcp/repo_identity_pending_test.go new file mode 100644 index 000000000..a054cded4 --- /dev/null +++ b/internal/mcp/repo_identity_pending_test.go @@ -0,0 +1,122 @@ +package mcp + +import ( + "context" + "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()) + 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) + } +} + +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, "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 "+linked) +} diff --git a/internal/mcp/repo_loading.go b/internal/mcp/repo_loading.go index 7a85efd25..6de08a523 100644 --- a/internal/mcp/repo_loading.go +++ b/internal/mcp/repo_loading.go @@ -36,6 +36,25 @@ 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 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 // configured repository whose load ended without registering it. func ServerInstructionsUnavailable(repo, reason string) string { 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/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/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/search_corpus_scope_test.go b/internal/mcp/search_corpus_scope_test.go index 5e51acdd0..786d7a26b 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) + 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/server.go b/internal/mcp/server.go index ce4ec8a14..e90b30573 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1577,6 +1577,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 { @@ -1672,6 +1682,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. @@ -1683,24 +1699,33 @@ 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 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) + } + 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) + } roots := agents.FamilyRepoRoots(s.trackedRepoRoots(), s.multiIndexer.ConfiguredRepoRoots()) if agents.PendingAutomaticCheckout(cwd, roots) { return ServerInstructionsPendingAutomaticCheckout(cwd, roots...) @@ -1711,6 +1736,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 @@ -2431,7 +2471,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 _, classified := s.multiIndexer.RepoLoadAdmissionForPath(cwd); classified { ss.scopeResolved = false } ss.scopeWorkspaceID = unresolvedWorkspacePrefix + cwd 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{ 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_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, 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_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_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()) diff --git a/internal/mcp/tools_review_rulepack_test.go b/internal/mcp/tools_review_rulepack_test.go index 2c7668063..d938323ca 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) + 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..7d9e5989b 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) + 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{ 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) 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)