Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1124 lines
47 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/daemon"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
gortexmcp "github.com/zzet/gortex/internal/mcp"
"github.com/zzet/gortex/internal/pathkey"
"github.com/zzet/gortex/internal/platform"
"github.com/zzet/gortex/internal/progress"
"github.com/zzet/gortex/internal/semantic/lsp"
"github.com/zzet/gortex/internal/serverstack"
)
// daemonState is the bundle of long-lived objects the daemon owns. One
// instance per running daemon; every session the daemon accepts shares
// these pointers.
type daemonState struct {
graph graph.Store
indexer *indexer.Indexer
multiIndexer *indexer.MultiIndexer
configManager *config.ConfigManager
mcpServer *gortexmcp.Server
// proxyHydrator lazily fills cross-daemon proxy-edge nodes from the
// owning remote's /v1/subgraph. nil unless federation.edges is on;
// the read path hydrates a proxy target before traversing it.
proxyHydrator *daemon.ProxyHydrator
// snapshotRepos carries per-repo FileMtimes restored from a daemon
// snapshot. Populated by buildDaemonState; consumed by
// warmupDaemonState to route each configured repo through
// ReconcileRepoCtx (incremental) instead of TrackRepoCtx (full
// index). nil or missing entries → fall back to full index.
snapshotRepos map[string]*snapshotRepo
// snapshotContracts carries the per-repo contract entries restored
// from the snapshot. Warmup injects these into each indexer after
// ReconcileRepoCtx when IncrementalReindex skipped re-extraction (no
// stale files). Without this the per-repo contracts.Registry stays
// nil for every quiescent repo, so `contracts` / `contracts check`
// return empty results even though the graph holds the nodes.
snapshotContracts map[string][]contracts.Contract
// snapshotPartial reports that the load shed stale records (dropped
// nodes / dropped edges whose target vanished). When true, warmup
// forces a full per-repo ResolveAll across every indexer instead of
// the incremental "only files whose mtime changed" path. Without
// this, edges that the loader dropped never come back — every
// restart erodes the graph further until exported methods like
// (*Node).Type show zero callers despite having dozens of real
// callers in source. The IncrementalReindex path never re-resolves
// unchanged files, so the lost edges are invisible to it.
snapshotPartial bool
// snapshotVector carries the workspace-global semantic-search
// vector index restored from the snapshot. When its Index is
// non-empty and an embedder is configured, warmupDaemonState
// restores it after the per-repo re-index loop (which it runs with
// vector building skipped) instead of re-embedding the whole graph.
snapshotVector snapshotVector
// MultiWatcher is built by warmupDaemonState (after tracked repos
// have been re-indexed) and handed to realController via
// AttachWatcher — it isn't held on daemonState because no caller
// reads it from here.
// resolverLSPRegistry composes per-repo ResolverHelpers consulted
// by the cross-file resolver's hot path. Populated as repos
// are tracked so each language-server instance is scoped to its owning
// workspace. nil when the resolve-time LSP path is disabled
// (GORTEX_LSP_RESOLVER=0) or when semantic enrichment is off.
resolverLSPRegistry *lsp.ResolverHelperRegistry
// lspRouter is the daemon-shared LSP server pool. Held here so
// the warmup loop can register per-repo helpers via
// ResolverHelperRegistry without re-deriving the router from the
// semantic manager.
lspRouter *lsp.Router
// overlays is the editor-overlay manager, retained so the HTTP
// handler can share the same instance the MCP server uses.
overlays *daemon.OverlayManager
// shared is the constructed server stack; its Close() runs the
// teardown chain (savings flush, backend close) at daemon shutdown.
shared *serverstack.SharedServer
}
// lspDisabledSet builds the set of LSP spec names that should NOT be
// auto-registered by Router.RegisterAvailable. Two inputs are merged:
//
// 1. Per-spec config overrides — any entry in `semantic.providers`
// with `enabled: false` whose name matches a known LSP spec.
// Already-disabled-by-config users keep their opt-out without
// having to also set the env var.
// 2. The GORTEX_LSP_DISABLE env var — comma-separated spec names.
// The literal value "all" or "*" disables auto-registration
// entirely (the explicit-config loop above still runs).
//
// The special key "__all__" in the returned map signals
// "skip auto-register everywhere" and is checked separately by
// callers; per-spec keys carry the spec.Name.
func lspDisabledSet(providers []config.SemanticProviderConfig, envVar string) map[string]bool {
return serverstack.LspDisabledSet(providers, envVar)
}
// buildDaemonState builds the daemon's stack through the shared
// serverstack constructor, applies the daemon-specific snapshot
// warm-start (memory backend only), and returns the long-lived
// daemonState the warmup loop and controller share.
func buildDaemonState(logger *zap.Logger) (*daemonState, error) {
cfg, err := config.Load(cfgFile)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
gc, _ := config.LoadGlobal()
// Fold --tools / --tools-mode into mcp.tools (flag overrides config;
// GORTEX_TOOLS still overrides). Survives --detach because the
// re-exec'd child re-parses the same flags.
applyToolPresetFlags(cfg, daemonTools, daemonToolsMode)
ss, err := serverstack.NewSharedServer(serverstack.SharedServerConfig{
Lifecycle: serverstack.LifecycleDaemon,
Backend: daemonBackend,
BackendPath: daemonBackendPath,
BufferPoolMB: resolveDaemonBufferPoolMB(),
Config: cfg,
Global: gc,
Logger: logger,
Version: version,
Embedder: serverstack.EmbedderRequest{
FlagChanged: daemonEmbeddingsChanged,
FlagEnabled: daemonEmbeddings,
FlagURL: daemonEmbeddingsURL,
FlagModel: daemonEmbeddingsModel,
},
// Workspace-global side-store layout: notes/memories partition
// under the "daemon" key in the shared DataDir sidecar; the
// notebook lives in a cache dir (no single repo git tree to
// anchor to); feedback/combo/frecency stay ephemeral.
SideStores: serverstack.SideStores{
NotesDir: platform.DataDir(),
NotesRepo: "daemon",
NotebookPath: filepath.Join(platform.DataDir(), "notebook-cache"),
},
})
if err != nil {
return nil, fmt.Errorf("build server stack: %w", err)
}
// Snapshot warm-start (memory backend only — the sqlite backend reads
// from its own on-disk store and needs no gob replay). Replays
// nodes/edges into the graph and carries the per-repo FileMtimes /
// contracts / vector index warmup needs. When the snapshot already
// holds a dimension-matching vector index, skip re-embedding the whole
// graph during warmup; warmupDaemonState restores the cached index.
var loadResult snapshotLoadResult
if mg, ok := ss.Graph.(*graph.Graph); ok {
loadResult, err = loadSnapshot(mg, logger)
if err != nil {
logger.Warn("daemon: snapshot load failed", zap.Error(err))
}
if ss.MultiIndexer != nil {
if vec := loadResult.Vector; len(vec.Index) > 0 && vec.Dims == ss.EmbedderDims {
ss.MultiIndexer.SetSkipVectorBuild(true)
logger.Info("daemon: snapshot carries vector index — warmup will restore it instead of re-embedding",
zap.Int("vectors", vec.Count), zap.Int("dims", vec.Dims))
}
}
}
return &daemonState{
graph: ss.Graph,
indexer: ss.Indexer,
multiIndexer: ss.MultiIndexer,
configManager: ss.ConfigMgr,
mcpServer: ss.MCP,
overlays: ss.Overlays,
shared: ss,
snapshotRepos: loadResult.Repos,
snapshotContracts: loadResult.Contracts,
snapshotPartial: loadResult.Partial,
snapshotVector: loadResult.Vector,
resolverLSPRegistry: ss.ResolverLSPRegistry,
lspRouter: ss.LSPRouter,
}, nil
}
// warmupTimings collects the per-phase costs of one warmupDaemonState run so
// the caller can emit a single summary line instead of reconstructing what a
// restart did from five differently-shaped per-phase log lines. Populated by
// capturing the same phaseStart/time.Since pairs the existing per-phase logs
// already compute — those logs stay untouched; this just also keeps the
// values around instead of discarding them.
type warmupTimings struct {
parse time.Duration
resolve time.Duration
globalResolve time.Duration
endBatch time.Duration
// reposChanged is the number of tracked repos whose reindex actually did
// work this warmup (cold track, or a reconcile with stale/deleted files).
reposChanged int
// filesReindexed sums, across every changed repo, either FileCount (a
// full retrack) or StaleFileCount+DeletedFileCount (an incremental
// reconcile).
filesReindexed int
// enrichScheduled is the number of repos whose deferred semantic
// enrichment was actually dispatched, as returned by RunDeferredPassesAll.
enrichScheduled int
}
// warmupDaemonState performs the per-repo parse loop, resolves references,
// runs the background enrichment, and brings up the MultiWatcher. Split out
// from buildDaemonState so the daemon can open its socket and accept
// connections before this work finishes. markReady is invoked once references
// are resolved and the graph is queryable — ahead of the slow enrichment pass
// — so the daemon reports ready as soon as find_usages / get_callers return
// complete results, not after enrichment finishes. The returned *warmupTimings
// is never nil — daemon.go uses it to emit a one-line warmup summary.
// healDuplicateRepos drops tracked-repo entries that name the same
// directory as an earlier entry under a different path spelling — letter
// case or Unicode normalisation on a case-insensitive filesystem — logs
// each drop, and persists the cleaned config. Returns the number of
// entries removed. It is the startup / load-time repair for configs that
// accumulated a duplicate before folding was applied (#270): a duplicate
// reaching the warmup loop would flip the daemon into multi-repo mode and
// desync the graph. The FIRST (oldest) spelling of each directory is kept.
func healDuplicateRepos(gc *config.GlobalConfig, logger *zap.Logger) int {
if gc == nil {
return 0
}
removed := gc.DedupeRepos()
if len(removed) == 0 {
return 0
}
if logger == nil {
logger = zap.NewNop()
}
for _, r := range removed {
kept := r.Path
for _, k := range gc.Repos {
if pathkey.SamePathIdentity(k.Path, r.Path) {
kept = k.Path
break
}
}
logger.Warn("dropping duplicate tracked-repo entry (same directory, different path spelling)",
zap.String("dropped", r.Path),
zap.String("kept", kept))
}
if err := gc.Save(); err != nil {
logger.Warn("persisting deduped repo config failed", zap.Error(err))
}
return len(removed)
}
func warmupDaemonState(state *daemonState, logger *zap.Logger, markReady func()) (*indexer.MultiWatcher, *warmupTimings) {
timings := &warmupTimings{}
if state.multiIndexer == nil || state.configManager == nil {
return nil, timings
}
ctx := progress.WithReporter(context.Background(), progress.Nop{})
// BeginParallelBatch / EndBatch tells every per-repo Indexer
// constructed inside the loop to skip both the graph-wide
// derivation passes (InferImplements / InferOverrides /
// markTestSymbolsAndEmitEdges) AND the per-repo cross-cutting
// passes (ResolveAll / semantic enrich / contract extract+commit).
// The latter mutate the shared graph in ways that race when
// goroutines run them concurrently across repos, so the parallel
// loop below just parses; RunDeferredPassesAll drains the deferred
// per-repo passes serially before the global resolve. Without this
// batch wrapper, a 100+ repo warmup is O(R · global_size).
state.multiIndexer.BeginParallelBatch()
// Heal any duplicate tracked-repo entries that name the same directory
// under a different path spelling (case, or Unicode normalisation)
// BEFORE the warmup loop registers them. Two spellings of one directory
// would otherwise both reach the indexer and flip the daemon into
// multi-repo mode, desyncing the unprefixed graph (#270).
healDuplicateRepos(state.configManager.Global(), logger)
repos := state.configManager.Global().Repos
// Purge orphaned repo prefixes BEFORE the warmup loop re-registers the
// tracked repos, while the store still reflects the prior run's state. A
// repo removed from config (or untracked before the sidecar-aware purge
// existed) can leave its nodes+edges AND its fifteen repo_prefix-keyed
// sidecar tables (file_mtimes, *_enrichment, symbol_fts, content_fts, ...)
// behind — a long-lived store then carries thousands of rows for a repo
// gone long ago. Key the tracked repos through the same EffectiveRepoPrefix
// the warm mtime lookup + LSP-helper registry use (so the comparison
// matches what the indexer actually wrote), ask the store for any DISTINCT
// prefix outside that set, and PurgeRepo each. '' is never an orphan
// (shared global externals / solo data). Capability-probed, so only the
// sidecar-bearing on-disk store participates; the in-memory store skips it.
if orphaner, ok := state.graph.(interface {
OrphanRepoPrefixes(known []string) []string
}); ok {
if purger, ok := state.graph.(interface{ PurgeRepo(string) error }); ok {
known := make([]string, 0, len(repos))
for _, entry := range repos {
p := strings.TrimPrefix(indexer.EffectiveRepoPrefix(state.configManager, entry), "/")
if p != "" {
known = append(known, p)
}
}
for _, prefix := range orphaner.OrphanRepoPrefixes(known) {
if err := purger.PurgeRepo(prefix); err != nil {
logger.Warn("daemon: purging orphaned repo prefix failed",
zap.String("prefix", prefix), zap.Error(err))
continue
}
logger.Info("daemon: purged orphaned repo prefix (no tracked repo in config keys under it)",
zap.String("prefix", prefix))
}
}
}
// One-time store compaction, guarded (see daemon_compact.go). Placed HERE
// on purpose: after the orphan purge, so the pages it just freed are
// measured and reclaimed in the same pass; before the warmup re-index loop
// and the resolver/enrichment passes, whose writes would reuse freelist
// pages mid-measurement and whose readers would contend with VACUUM's
// exclusive lock. The socket is already listening (it opens before this
// goroutine by design), but at this point in boot the daemon's own
// workload is quiet and a rare early client query either finishes first or
// makes the VACUUM fail cleanly at busy_timeout — a skip, not a fault.
// Running before the socket opened instead would hold the daemon
// unreachable for the whole VACUUM, turning a compacting boot into an
// apparent hang.
maybeCompactStore(state.graph, logger)
// Register a per-repo resolver-time LSP helper for every
// tracked repo BEFORE the parallel warmup loop fires. The
// helpers are lazy: language servers are not spawned until the
// resolver asks for a supported edge resolution, so there's no
// startup cost for repos with no matching code.
if state.resolverLSPRegistry != nil && state.lspRouter != nil {
poolSize := lsp.ResolverPoolSizeFromEnv(1)
registered, skipped, tsRepos, pythonRepos := 0, 0, 0, 0
for _, entry := range repos {
absRoot, err := filepath.Abs(entry.Path)
if err != nil {
continue
}
helper, specs := serverstack.BuildResolverLSPHelperForRepo(state.lspRouter, absRoot, poolSize, logger)
if helper == nil {
skipped++
continue
}
prefix := strings.TrimPrefix(indexer.EffectiveRepoPrefix(state.configManager, entry), "/")
state.resolverLSPRegistry.Register(prefix, helper)
registered++
for _, spec := range specs {
switch spec {
case "typescript-language-server":
tsRepos++
case "pyright":
pythonRepos++
}
}
}
logger.Info("daemon: resolve-time LSP helpers registered",
zap.Int("repos", registered),
zap.Int("skipped", skipped),
zap.Int("ts_repos", tsRepos),
zap.Int("python_repos", pythonRepos),
zap.Int("pool_size", poolSize))
}
// Bounded worker pool — disk I/O dominates parsing for most repos,
// but a few CPU-heavy ones overlap with disk waits on others. NumCPU
// gives good throughput on local SSDs without thrashing slow
// external mounts (which dominate at this scale). Capped so a 32-core
// box doesn't over-subscribe a single spinning drive.
workers := runtime.NumCPU()
if workers < 2 {
workers = 2
}
if workers > 12 {
workers = 12
}
if workers > len(repos) {
workers = len(repos)
}
logger.Info("daemon: warmup phase start",
zap.String("phase", "parallel_parse"),
zap.Int("repos", len(repos)),
zap.Int("workers", workers),
zap.Bool("snapshot_partial_forces_full_walk", state.snapshotPartial))
publishReadinessPhase(state, "parallel_parse", false, map[string]any{
"tracked_repos": len(repos),
"workers": workers,
})
phaseStart := time.Now()
jobs := make(chan config.RepoEntry, len(repos))
var wg sync.WaitGroup
// changedRepos counts repos that actually did indexing work this
// warmup: a cold full-track, or a reconcile that re-indexed / evicted
// at least one file. When it stays zero, NOTHING on disk changed
// since the last shutdown, so the persisted graph already holds every
// resolved and derived edge — the global resolution passes below
// (RunDeferredPassesAll / RunGlobalResolve / RunGlobalGraphPasses) are
// pure recomputation and get skipped, which is what makes a true warm
// restart near-instant instead of replaying the full cold-warmup cost.
var changedRepos atomic.Int64
// filesReindexed sums the actual file-level work behind changedRepos: a
// full retrack counts its whole FileCount, an incremental reconcile
// counts only the files it touched (stale + deleted). Read once into
// timings.filesReindexed after wg.Wait() below.
var filesReindexed atomic.Int64
// changedPrefixes records the repo prefix of every repo that did indexing
// work, so the end-of-warmup RunGlobalGraphPasses can scope the per-repo
// clone detection + Rebuild to just those repos instead of every tracked
// repo. scopeUnknown trips when a changed repo's prefix can't be
// determined (e.g. a failed reconcile) — then the scope is dropped and the
// whole-workspace clone pass runs, degrading toward correctness.
var changedPrefixes sync.Map
var scopeUnknown atomic.Bool
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for entry := range jobs {
// Per-entry panic guard so one repo's crash during
// reindex doesn't kill the worker — the bad repo logs
// and skips, the worker proceeds to the next job, and
// warmup completes.
func(entry config.RepoEntry) {
defer func() {
if r := recover(); r != nil {
logger.Error("daemon: warmup repo panic recovered",
zap.String("path", entry.Path),
zap.Any("panic", r))
}
}()
// Route repos whose nodes came from the snapshot through
// ReconcileRepoCtx — it calls IncrementalReindex, which
// evicts files deleted while the daemon was down and
// re-indexes only files whose mtime changed. Repos not in
// the snapshot (newly tracked, or first startup after a
// schema bump) fall back to TrackRepoCtx, which does a
// full walk. Both paths end with the repo registered on
// the MultiIndexer; contract reconciliation is deferred
// to the single RunGlobalResolve call below.
//
// snapshotPartial == true forces the full-walk path even
// when prior mtimes exist: the partial-load signal means
// the persisted resolution state is no longer trustworthy
// (stale edges were dropped because their targets vanished),
// and the incremental path only re-resolves files whose
// mtime changed — so the dropped edges would never come
// back. Without this override every restart progressively
// erodes the graph until exported methods show zero
// callers despite having dozens of real call sites.
repoStart := time.Now()
// Prefer mtimes stored in the backend's FileMtime
// sidecar table — that lifts the persistence off the
// gob snapshot for disk-backed backends, which is the
// path that actually rebuilds across restarts. Falls
// back to the snapshot's per-repo FileMtimes when the
// backend doesn't implement the reader (memory) or
// hasn't seen this repo yet.
priorMtimes := priorMtimesFromStore(state.graph, state.configManager, entry, logger)
if len(priorMtimes) == 0 {
priorMtimes = priorMtimesForEntry(state.snapshotRepos, entry)
}
if state.snapshotPartial {
priorMtimes = nil
}
// A backend that crossed a schema-rebuild migration rung
// (NeedsRebuild) has on-disk rows in the old shape that an
// incremental reconcile cannot fix. Drop prior mtimes so every
// file re-indexes into the new schema (the nil branch below
// runs a full TrackRepoCtx and marks the repo changed, so the
// global resolve/derivation passes re-run too). No-op for
// backends without the capability and whenever no rebuild rung
// was crossed — the common case.
if storeNeedsRebuild(state.graph) {
if len(priorMtimes) > 0 {
logger.Info("daemon: backend signalled schema rebuild; forcing full re-index",
zap.String("path", entry.Path))
}
priorMtimes = nil
}
pathFn := "track"
if priorMtimes != nil {
pathFn = "reconcile"
res, err := state.multiIndexer.ReconcileRepoCtx(ctx, entry, priorMtimes)
switch {
case err != nil:
logger.Warn("daemon: startup reconcile failed",
zap.String("path", entry.Path), zap.Error(err))
// Treat a failed reconcile as "changed" so the global
// passes still run — degrade toward correctness, not
// toward the fast path, when we can't trust the delta.
changedRepos.Add(1)
scopeUnknown.Store(true)
case res != nil && (res.StaleFileCount > 0 || res.DeletedFileCount > 0 || len(res.FailedFiles) > 0 || res.FullRetrack):
changedRepos.Add(1)
filesReindexed.Add(int64(reconcileFileCount(res)))
if res.RepoPrefix != "" {
changedPrefixes.Store(res.RepoPrefix, struct{}{})
} else {
scopeUnknown.Store(true)
}
default:
// Warm no-op path: the repo re-indexed nothing, so its
// graph is served straight from the persisted store.
// Vet the freshly-recomputed per-repo counts against
// what the snapshot recorded — a material shortfall
// means the store came back shape-degraded relative to
// the snapshot metadata (a persisted resolution
// regression). Mark the repo changed so the
// end-of-warmup global re-resolve + derivation passes
// run for it instead of silently serving the shrunken
// graph, and surface the event so a ratchet can't hide
// behind an all-green index_health.
if res != nil && bootShapeShortfall(state.snapshotRepos, res.RepoPrefix, res.NodeCount, res.EdgeCount) {
indexer.RecordResolutionRegression()
logger.Warn("daemon: boot shape-degradation guard — repo graph materially short of snapshot; re-running resolution",
zap.String("prefix", res.RepoPrefix),
zap.Int("live_nodes", res.NodeCount),
zap.Int("live_edges", res.EdgeCount))
changedRepos.Add(1)
if res.RepoPrefix != "" {
changedPrefixes.Store(res.RepoPrefix, struct{}{})
} else {
scopeUnknown.Store(true)
}
}
}
} else {
// No prior mtimes → full cold (re)index of this repo,
// which is "changed" by definition.
changedRepos.Add(1)
if res, err := state.multiIndexer.TrackRepoCtx(ctx, entry); err != nil {
logger.Warn("daemon: startup track failed",
zap.String("path", entry.Path), zap.Error(err))
scopeUnknown.Store(true)
} else if res != nil && res.RepoPrefix != "" {
// A cold TrackRepoCtx is itself a full retrack — its
// FileCount is the whole repo's file-level work.
filesReindexed.Add(int64(res.FileCount))
changedPrefixes.Store(res.RepoPrefix, struct{}{})
} else {
scopeUnknown.Store(true)
}
}
elapsed := time.Since(repoStart)
if elapsed > 2*time.Second {
logger.Info("daemon: warmup repo elapsed",
zap.String("path", entry.Path),
zap.String("path_fn", pathFn),
zap.Duration("elapsed", elapsed))
}
}(entry)
}
}()
}
for _, entry := range repos {
jobs <- entry
}
close(jobs)
wg.Wait()
timings.parse = time.Since(phaseStart)
timings.filesReindexed = int(filesReindexed.Load())
logger.Info("daemon: warmup phase done",
zap.String("phase", "parallel_parse"),
zap.Duration("elapsed", time.Since(phaseStart)))
parseStats := progress.Stats(string(progress.PhaseParse), phaseStart, len(repos), len(repos))
publishReadinessPhase(state, "parallel_parse_done", false, map[string]any{
"tracked_repos": len(repos),
"elapsed_ms": time.Since(phaseStart).Milliseconds(),
"elapsed_human": parseStats.Elapsed,
"repos_per_sec": parseStats.ItemsPerSec,
})
// Warm-restart fast path. When the reconcile loop above re-indexed
// nothing, the persistent backend already carries every resolved and
// derived edge from the prior run; the deferred per-repo passes, the
// cross-repo resolve, and the graph-wide derivation passes would all
// just recompute what's on disk. Skipping them is what turns a warm
// restart from a multi-minute replay of the cold-warmup cost into a
// near-instant "open store, reconcile zero files, start watching".
// The in-memory backend reaches here too, but its snapshot replay
// already restored the derived edges, so the skip is equally safe.
anyChanged := changedRepos.Load() > 0
timings.reposChanged = int(changedRepos.Load())
logger.Info("daemon: warmup change detection",
zap.Int64("changed_repos", changedRepos.Load()),
zap.Int("tracked_repos", len(repos)),
zap.Bool("global_passes", anyChanged))
// Materialize the changed-repo prefix set once. It feeds two consumers:
// the warm-restart resolve scope (RunPreEnrichResolve) and the end-batch
// clone-pass scope (ArmBatchScope). Building it once here avoids two
// separate walks of the sync.Map.
changed := make(map[string]struct{})
changedPrefixes.Range(func(k, _ any) bool {
if p, ok := k.(string); ok {
changed[p] = struct{}{}
}
return true
})
// Resolve scope: restrict the warm-restart master resolve to the repos
// that re-indexed, but only when every whole-graph-safety precondition
// holds (see warmupResolveScope). Any uncertainty drops to a nil scope ==
// whole-graph resolve, exactly the pre-scoping behaviour. The scoped
// global passes switch is honoured downstream in runMasterResolve,
// matching ArmBatchScope.
resolveScope := warmupResolveScope(changed, len(repos), anyChanged,
scopeUnknown.Load(), state.snapshotPartial, storeNeedsRebuild(state.graph))
// Resolve references ahead of the slow enrichment pass so find_usages /
// get_callers return complete results as soon as the daemon reports ready
// — independent of semantic enrichment. RunPreEnrichResolve materialises
// go.mod dep nodes, runs the same-repo master resolver, and runs the
// cross-repo resolver. On the warm-restart fast path nothing changed, so
// the persisted graph already carries resolved edges and we skip straight
// to marking ready.
if anyChanged {
phaseStart = time.Now()
publishReadinessPhase(state, "resolve", false, nil)
state.multiIndexer.RunPreEnrichResolve(ctx, resolveScope)
timings.resolve = time.Since(phaseStart)
logger.Info("daemon: warmup phase done",
zap.String("phase", "resolve"),
zap.Duration("elapsed", time.Since(phaseStart)))
publishReadinessPhase(state, "resolve_done", false, map[string]any{
"elapsed_ms": time.Since(phaseStart).Milliseconds(),
})
}
// References are resolved (or unchanged and already resolved on the warm
// path): the graph is queryable. Flip ready before the multi-minute
// enrichment so clients can start issuing queries immediately. Everything
// below runs in the background after ready and finishes at MarkEnriched.
if markReady != nil {
markReady()
}
// Resume enrichment for any repo a prior process left partial / abandoned.
// pendingEnrich reflects only this run's re-indexing work, so an unchanged
// repo whose completion marker is absent (a cut-short pass writes none)
// would never re-run its semantic pass. Seeding re-arms the gate from the
// persisted marker so the deferred pass below resumes it — and runs that
// block even on a warm restart that changed nothing on disk (anyChanged is
// false). Cheap for a fully-enriched workspace: each already-complete repo
// pays only a git rev-parse plus one marker lookup.
enrichPending := state.multiIndexer.SeedPendingEnrichAll()
if enrichPending > 0 && !anyChanged {
logger.Info("daemon: warmup resuming incomplete enrichment on an otherwise-unchanged restart",
zap.Int("repos_pending_enrich", enrichPending))
}
// Drain deferred per-repo passes (semantic enrich / contract
// extract+commit) serially across the indexers the parallel loop
// populated. These run after ready: enrichment is a precision upgrade on
// top of the already-queryable reference graph. RunDeferredPassesAll
// re-runs the master resolver at its tail to lift placeholder edges the
// enrichment + contract passes add.
if anyChanged || enrichPending > 0 {
phaseStart = time.Now()
publishReadinessPhase(state, "deferred_passes_all", true, nil)
timings.enrichScheduled = state.multiIndexer.RunDeferredPassesAll(ctx)
logger.Info("daemon: warmup phase done",
zap.String("phase", "deferred_passes_all"),
zap.Duration("elapsed", time.Since(phaseStart)))
publishReadinessPhase(state, "deferred_passes_all_done", true, map[string]any{
"elapsed_ms": time.Since(phaseStart).Milliseconds(),
})
}
// Rehydrate per-repo contract registries from the snapshot. Only
// target indexers whose registry is still nil — a non-nil registry
// means IncrementalReindex (or a fresh TrackRepoCtx) re-extracted
// contracts from source, and that result is authoritative. Without
// this, every steady-state repo's ContractRegistry stays nil and
// MergedContractRegistry skips them, so `contracts` returns only
// the contracts of repos whose files happened to change since the
// last shutdown.
{
phaseStart = time.Now()
injectedRepos, injectedCount := 0, 0
for prefix := range state.multiIndexer.AllMetadata() {
idx := state.multiIndexer.GetIndexer(prefix)
if idx == nil || idx.ContractRegistry() != nil {
continue
}
// Primary path: rebuild the per-repo registry from
// KindContract nodes already in the backend's graph.
// The indexer stamps every contract record onto
// Node.Meta at commit time, so the graph is the
// authoritative source — no gob round-trip needed.
reg := contracts.LoadRegistryFromGraph(state.graph, prefix)
if reg == nil {
// Fallback to the legacy gob-snapshot path for
// daemons upgrading across this change. The
// snapshot copy is read-only by this point so the
// two sources can't drift mid-flight.
cs, ok := state.snapshotContracts[prefix]
if !ok || len(cs) == 0 {
continue
}
reg = contracts.NewRegistry()
for _, c := range cs {
reg.Add(c)
}
}
idx.SetContractRegistry(reg)
injectedRepos++
injectedCount += len(reg.All())
}
if injectedRepos > 0 {
logger.Info("daemon: rehydrated contract registries from graph/snapshot",
zap.Int("repos", injectedRepos),
zap.Int("contracts", injectedCount),
zap.Duration("elapsed", time.Since(phaseStart)))
}
}
// Backfill `WorkspaceID` / `ProjectID` onto nodes and contracts
// loaded from a legacy snapshot. Old snapshots have these fields
// as zero (gob decodes unknown fields silently); without this
// stamp the matcher's EffectiveWorkspace falls back to RepoPrefix
// and explicit shared-workspace declarations stop working until
// every file is touched. Idempotent — re-running on a stamped
// graph is a no-op.
phaseStart = time.Now()
if nodes, conts := state.multiIndexer.BackfillWorkspaceSlugs(); nodes+conts > 0 {
logger.Info("daemon: backfilled workspace/project slugs from .gortex.yaml",
zap.Int("nodes", nodes),
zap.Int("contracts", conts),
zap.Duration("elapsed", time.Since(phaseStart)))
}
// Run a cross-repo resolution pass once warmup has stamped the
// workspace slugs. Files touched by IncrementalReindex already
// re-resolve via the per-repo Resolver; this catches cross-repo
// edges in unchanged files plus stamps cross_workspace_deps
// eligibility on stubs. Mirrors what MultiIndexer.IndexAll does
// for a fresh-start daemon (where there's no snapshot to reconcile
// against). After resolution, contract bridge edges may have
// changed too, so ReconcileContractEdges runs again.
if anyChanged {
phaseStart = time.Now()
publishReadinessPhase(state, "global_resolve", true, nil)
state.multiIndexer.RunGlobalResolve()
timings.globalResolve = time.Since(phaseStart)
logger.Info("daemon: warmup phase done",
zap.String("phase", "global_resolve"),
zap.Duration("elapsed", time.Since(phaseStart)))
publishReadinessPhase(state, "global_resolve_done", true, map[string]any{
"elapsed_ms": time.Since(phaseStart).Milliseconds(),
})
}
// Finish the batch: turn off the per-repo skip flag and run the
// graph-wide derivation passes once. RunGlobalResolve above just
// lifted the last cross-repo placeholder EdgeCalls, so EdgeTests
// derivation here picks up cross-repo test→subject pairs that
// were unresolved during the per-repo loop. On the warm-restart fast
// path (nothing changed) ResetBatch clears the deferred-batch flags
// without re-running those passes — the persisted graph already has
// the derived edges.
// Scope the per-repo clone detection + clone-index Rebuild in the
// end_batch graph passes to the repos that actually re-indexed this
// warmup. Dropped when a changed repo's prefix was indeterminate
// (scopeUnknown) so a repo whose clones genuinely need recomputing is
// never skipped. ArmBatchScope is a no-op when scoped global passes are
// disabled or the set is empty (run every repo, the prior behaviour).
if anyChanged && !scopeUnknown.Load() {
state.multiIndexer.ArmBatchScope(changed)
}
phaseStart = time.Now()
publishReadinessPhase(state, "end_batch", true, nil)
if anyChanged {
state.multiIndexer.EndBatch()
} else {
state.multiIndexer.ResetBatch()
}
timings.endBatch = time.Since(phaseStart)
logger.Info("daemon: warmup phase done",
zap.String("phase", "end_batch"),
zap.Duration("elapsed", time.Since(phaseStart)))
publishReadinessPhase(state, "end_batch_done", true, map[string]any{
"elapsed_ms": time.Since(phaseStart).Milliseconds(),
})
// Restore the workspace vector index from the snapshot. The warmup
// loop above ran with vector building skipped (SetSkipVectorBuild),
// so the search backend is text-only at this point; ImportVectorIndex
// wraps it into a HybridBackend with the cached vectors. This is the
// step that lets a default-on daemon avoid re-embedding the whole
// graph on every restart. SetSkipVectorBuild(false) afterwards means
// any later file-change re-index rebuilds vectors normally.
if vec := state.snapshotVector; len(vec.Index) > 0 {
phaseStart = time.Now()
if err := state.multiIndexer.ImportVectorIndex(vec.Index, vec.Dims, vec.Count); err != nil {
logger.Warn("daemon: vector index restore failed — semantic search will rebuild on next index",
zap.Error(err))
} else {
logger.Info("daemon: restored vector index from snapshot",
zap.Int("vectors", vec.Count),
zap.Int("dims", vec.Dims),
zap.Duration("elapsed", time.Since(phaseStart)))
}
state.multiIndexer.SetSkipVectorBuild(false)
}
watchCfgs := make(map[string]config.WatchConfig)
for prefix := range state.multiIndexer.AllMetadata() {
watchCfgs[prefix] = state.configManager.GetRepoConfig(prefix).Watch
}
mw, err := indexer.NewMultiWatcher(state.multiIndexer, watchCfgs, logger)
if err != nil {
logger.Warn("daemon: multi-watcher init failed", zap.Error(err))
return nil, timings
}
if err := mw.Start(); err != nil {
logger.Warn("daemon: multi-watcher start failed", zap.Error(err))
return nil, timings
}
logger.Info("daemon: watching", zap.Int("repos", len(watchCfgs)))
publishReadinessPhase(state, "watcher_started", true, map[string]any{
"watched_repos": len(watchCfgs),
})
return mw, timings
}
// logWarmupSummary emits the one-line warmup recap: reconstructing what a
// restart did otherwise means joining the parallel_parse / resolve /
// deferred_passes_all / global_resolve / end_batch per-phase log lines (each
// logged separately, above, as the phases run) plus the separate "graph
// queryable" / "enrichment complete" lines. queryable is the elapsed time at
// markReady (find_usages / get_callers become complete); total is the
// elapsed time at MarkEnriched (the full warmup, including enrichment).
func logWarmupSummary(logger *zap.Logger, warmup *warmupTimings, queryable, total time.Duration) {
if logger == nil || warmup == nil {
return
}
logger.Info("daemon: warmup summary",
zap.Float64("parse_s", warmup.parse.Seconds()),
zap.Float64("resolve_s", warmup.resolve.Seconds()),
zap.Float64("global_resolve_s", warmup.globalResolve.Seconds()),
zap.Float64("end_batch_s", warmup.endBatch.Seconds()),
zap.Float64("queryable_s", queryable.Seconds()),
zap.Int("repos_changed", warmup.reposChanged),
zap.Int("files_reindexed", warmup.filesReindexed),
zap.Int("enrich_scheduled", warmup.enrichScheduled),
zap.Float64("total_s", total.Seconds()))
}
// publishReadinessPhase forwards a workspace_readiness phase
// transition to the MCP server's readiness broadcaster. Safe to
// call when the server isn't wired (single-process modes that
// bypass the daemon).
func publishReadinessPhase(state *daemonState, phase string, ready bool, extra map[string]any) {
if state == nil || state.mcpServer == nil {
return
}
state.mcpServer.PublishReadiness(phase, ready, extra)
}
// priorMtimesFromStore asks the backend for its persisted FileMtime
// rows for the repo described by entry. Returns nil when the backend
// doesn't implement the reader (in-memory backend) or has no recorded
// mtimes for the repo (fresh cold start). When non-nil it short-
// circuits the gob-snapshot lookup so the warm path is driven by
// data the backend persisted itself.
func priorMtimesFromStore(g graph.Store, cm *config.ConfigManager, entry config.RepoEntry, logger *zap.Logger) map[string]int64 {
reader, ok := g.(graph.FileMtimeReader)
if !ok {
if logger != nil {
logger.Info("daemon: priorMtimesFromStore: store does not implement FileMtimeReader")
}
return nil
}
// Key by the prefix the indexer actually registers the repo under —
// a worktree instance persists its mtimes under `<base>@<workspace>`,
// not the bare basename, so a plain ResolvePrefix would load the
// canonical checkout's mtimes and force a full re-index every restart.
effective := strings.TrimPrefix(indexer.EffectiveRepoPrefix(cm, entry), "/")
repoCount := 1
if cm != nil {
if g := cm.Global(); g != nil {
repoCount = len(g.Repos)
}
}
prefix, ok := warmMtimePrefix(effective, repoCount)
if !ok {
if logger != nil {
logger.Info("daemon: priorMtimesFromStore: empty prefix",
zap.String("entry_path", entry.Path),
zap.String("entry_name", entry.Name))
}
return nil
}
mtimes := reader.LoadFileMtimes(prefix)
if logger != nil {
logger.Info("daemon: priorMtimesFromStore loaded",
zap.String("prefix", prefix),
zap.Bool("single_repo", repoCount < 2),
zap.Int("count", len(mtimes)))
}
return mtimes
}
// warmMtimePrefix picks the repo_prefix to look up persisted file mtimes
// (and, by extension, to decide whether the warm-restart reconcile can run)
// for a repo whose EffectiveRepoPrefix is `effective` in a daemon tracking
// `repoCount` repos total.
//
// PURPOSE: single-repo daemons index WITHOUT a prefix — MultiIndexer.
// indexSingleRepo / ReconcileRepoCtx only switch on a repo prefix once a
// SECOND repo joins (the willBeMultiRepo gate). So a lone repo's nodes and
// file_mtimes rows are persisted under "", while EffectiveRepoPrefix returns
// the path basename (e.g. "drools"). Looking mtimes up under the basename
// finds zero rows and forces a full cold re-index — and, with an API
// embedder, a full (paid) re-embed — on every restart.
//
// RATIONALE: mirror the indexer's own single-vs-multi decision here so the
// warm path keys mtimes exactly where they were written. In multi-repo mode
// an empty effective prefix is untrustworthy (it would collide across repos),
// so report ok=false and let the caller fall back to a cold index.
//
// KEYWORDS: warm-restart, repo-prefix, single-repo, file_mtimes, re-embed
func warmMtimePrefix(effective string, repoCount int) (prefix string, ok bool) {
if repoCount < 2 {
return "", true
}
if effective == "" {
return "", false
}
return effective, true
}
// reconcileFileCount returns the number of files to count as "reindexed"
// for one repo's ReconcileRepoCtx result, for the warmup summary's
// filesReindexed total. A full retrack has no enumerated changed-file set
// (see the fullRetrack comment in ReconcileRepoCtx), so its whole FileCount
// stands in for the work done; an incremental reconcile counts only the
// files it actually touched (stale + deleted).
func reconcileFileCount(res *indexer.IndexResult) int {
if res == nil {
return 0
}
if res.FullRetrack {
return res.FileCount
}
return res.StaleFileCount + res.DeletedFileCount
}
// storeNeedsRebuild reports whether the backend signalled, via the optional
// NeedsRebuild capability, that a schema migration crossed a rung an ALTER
// could not satisfy — so its persisted rows are in an old shape and the
// warm/incremental reconcile must be bypassed for a full re-index. This is a
// generic, opt-in capability probe: a backend implements NeedsRebuild() bool
// to participate. The on-disk sqlite store does — it reports true for the one
// open in which it dropped an incompatible-schema database and recreated it
// empty (see store_sqlite.Store.NeedsRebuild). The in-memory store does not.
func storeNeedsRebuild(g any) bool {
rb, ok := g.(interface{ NeedsRebuild() bool })
return ok && rb.NeedsRebuild()
}
// warmupFullResolveForced reports whether the operator pinned the warm-restart
// master resolve to the whole graph via GORTEX_WARMUP_FULL_RESOLVE=1 (or
// "true"), overriding the changed-repo scoping. An escape hatch for the case
// where a scoped resolve is suspected of missing edges.
func warmupFullResolveForced() bool {
v := os.Getenv("GORTEX_WARMUP_FULL_RESOLVE")
return v == "1" || strings.EqualFold(v, "true")
}
// warmupResolveScope decides the changed-repo scope for the warm-restart
// master resolve. It returns the changed set only when scoping is both safe
// and beneficial; any whole-graph-safety precondition failure returns nil,
// which runMasterResolve treats as a whole-graph resolve (the pre-scoping
// behaviour). The guards mirror the reasons the reconcile loop already
// distrusts a partial delta:
//
// - !anyChanged: nothing re-indexed, the resolve is skipped entirely.
// - scopeUnknown: a changed repo's prefix was indeterminate — any per-repo
// uncertainty forces the whole-graph pass.
// - snapshotPartial: the load shed edges only a full pass can restore.
// - needsRebuild: the backend crossed a schema-rebuild rung.
// - GORTEX_WARMUP_FULL_RESOLVE=1: operator override.
// - empty / all-repos-changed: scoping gains nothing over the full pass.
func warmupResolveScope(changed map[string]struct{}, totalRepos int, anyChanged, scopeUnknown, snapshotPartial, needsRebuild bool) map[string]struct{} {
switch {
case !anyChanged, scopeUnknown, snapshotPartial, needsRebuild, warmupFullResolveForced():
return nil
case len(changed) == 0, len(changed) >= totalRepos:
return nil
default:
return changed
}
}
// priorMtimesForEntry finds the snapshotted FileMtimes map for a
// configured repo entry, matching on absolute RootPath. Falls back to
// prefix-based lookup when no path match is found — useful if the
// user's config moved but the prefix is stable. Returns nil when no
// match exists (first startup, schema bump, or newly-added repo).
func priorMtimesForEntry(repos map[string]*snapshotRepo, entry config.RepoEntry) map[string]int64 {
if len(repos) == 0 {
return nil
}
absPath, err := filepath.Abs(entry.Path)
if err != nil {
absPath = entry.Path
}
for _, r := range repos {
if r == nil {
continue
}
if r.RootPath == absPath {
return r.FileMtimes
}
}
if prefix := config.ResolvePrefix(entry); prefix != "" && prefix != "." {
if r := repos[prefix]; r != nil {
return r.FileMtimes
}
}
return nil
}
// collectSnapshotRepos snapshots the per-repo metadata needed to
// reconcile the next startup: RepoPrefix, RootPath, and FileMtimes.
// Called from the shutdown and periodic-snapshot paths so restart
// warmups can run IncrementalReindex instead of a full walk.
func collectSnapshotRepos(mi *indexer.MultiIndexer) []snapshotRepo {
if mi == nil {
return nil
}
meta := mi.AllMetadata()
if len(meta) == 0 {
return nil
}
out := make([]snapshotRepo, 0, len(meta))
for prefix, m := range meta {
if m == nil {
continue
}
// Copy the mtimes map — saveSnapshot encodes asynchronously
// on shutdown and we don't want a late watcher event mutating
// the live map mid-encode.
mtimes := make(map[string]int64, len(m.FileMtimes))
for k, v := range m.FileMtimes {
mtimes[k] = v
}
out = append(out, snapshotRepo{
RepoPrefix: prefix,
RootPath: m.RootPath,
FileMtimes: mtimes,
NodeCount: m.NodeCount,
EdgeCount: m.EdgeCount,
})
}
return out
}
// collectSnapshotContracts flattens every per-repo contract registry
// into a single wire-form slice ordered by repo prefix. The warmup path
// will redistribute by RepoPrefix when loading, so cross-repo ordering
// is irrelevant here; the stable per-prefix grouping just keeps logs
// and diffs readable. Called at the same points as collectSnapshotRepos
// so the header counts and the repo/contract records agree.
func collectSnapshotContracts(mi *indexer.MultiIndexer) []snapshotContract {
if mi == nil {
return nil
}
prefixes := make([]string, 0)
for prefix := range mi.AllMetadata() {
prefixes = append(prefixes, prefix)
}
sort.Strings(prefixes)
var out []snapshotContract
for _, prefix := range prefixes {
idx := mi.GetIndexer(prefix)
if idx == nil {
continue
}
reg := idx.ContractRegistry()
if reg == nil {
continue
}
for _, c := range reg.All() {
out = append(out, toSnapshotContract(c))
}
}
return out
}
// collectSnapshotVector serializes the workspace-global semantic-search
// vector index for the snapshot. The daemon's search backend is shared
// across every tracked repo, so there is exactly one vector index;
// MultiIndexer.ExportVectorIndex returns an empty blob when embeddings
// are disabled or no vectors were built, in which case the snapshot
// simply carries no vector data and the next warmup re-embeds.
func collectSnapshotVector(mi *indexer.MultiIndexer) snapshotVector {
if mi == nil {
return snapshotVector{}
}
data, dims, count := mi.ExportVectorIndex()
return snapshotVector{Index: data, Dims: dims, Count: count}
}