Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions cmd/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,28 @@ func runIndexer(cmd *cobra.Command, cfg *config.ConfigService, emb *embedder.Fai
}
defer lock.Release()

// Cancel context on SIGTERM or SIGINT so the indexer stops cleanly and
// the deferred lock.Release() runs before exit.
// Install signal handling before donor seeding: copying a large sibling
// index can take seconds, and cancellation must be able to close/remove the
// seed temp file before the process exits.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

// Reuse a sibling worktree's index for a brand-new database instead of
// re-embedding from scratch. The index lock serializes CLI indexers, while
// SeedFromDonor's seed lock also serializes this copy with MCP callers.
// A forced rebuild cannot reuse the copied embeddings, so skip the donor
// copy in that mode.
force, _ := cmd.Flags().GetBool("force")
if !force {
seedFromDonorIfNew(ctx, dbPath, projectPath, emb.ModelName(), logger, seedOptions{
status: p.Info,
})
}
if ctx.Err() != nil {
logger.Info("indexing cancelled by signal", "project", projectPath)
return
}

idx, setupErr := setupIndexer(cfg, emb, dbPath, logger)
if setupErr != nil {
err = setupErr
Expand All @@ -264,7 +281,7 @@ func runIndexer(cmd *cobra.Command, cfg *config.ConfigService, emb *embedder.Fai
defer func() { _ = idx.Close() }()

start := time.Now()
stats, err = performIndexing(ctx, cmd, idx, projectPath, p)
stats, err = performIndexing(ctx, force, idx, projectPath, p)
elapsed = time.Since(start).Round(time.Millisecond)
if err != nil && ctx.Err() != nil {
// A signal arrived; treat as clean exit.
Expand All @@ -274,9 +291,7 @@ func runIndexer(cmd *cobra.Command, cfg *config.ConfigService, emb *embedder.Fai
return
}

func performIndexing(ctx context.Context, cmd *cobra.Command, idx *index.Indexer, projectPath string, p *tui.Progress) (index.Stats, error) {
force, _ := cmd.Flags().GetBool("force")

func performIndexing(ctx context.Context, force bool, idx *index.Indexer, projectPath string, p *tui.Progress) (index.Stats, error) {
progress := p.AsProgressFunc()

if force {
Expand Down
92 changes: 92 additions & 0 deletions cmd/seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"
"log/slog"
"os"

"github.com/ory/lumen/internal/config"
"github.com/ory/lumen/internal/index"
)

type seedOptions struct {
findDonor func(string, string) string
seed func(context.Context, string, string, string) (bool, error)
status func(string)
}

// seedFromDonorIfNew seeds dbPath from a sibling worktree's index when dbPath
// does not yet exist, so a fresh git worktree reuses an already-indexed
// worktree's embeddings instead of re-embedding every file from scratch.
//
// Callers may additionally hold the index lock for dbPath, but SeedFromDonor
// has its own advisory lock so CLI and MCP callers cannot duplicate the copy.
// Seeding is best-effort — any failure is logged and indexing continues with a
// from-scratch build. The returned warning is suitable for surfacing to MCP
// clients. When dbPath already exists it is a single stat on the hot path.
func seedFromDonorIfNew(ctx context.Context, dbPath, projectPath, model string, logger *slog.Logger, opts seedOptions) string {
if _, err := os.Stat(dbPath); !os.IsNotExist(err) {
// Exists already, or stat failed for some other reason — nothing to do.
return ""
}

findDonor := opts.findDonor
if findDonor == nil {
findDonor = config.FindDonorIndex
}
donorPath := findDonor(projectPath, model)
if donorPath == "" {
return ""
}

logger.Info("seeding index from donor worktree",
"project_path", projectPath,
"donor_path", donorPath,
)
if opts.status != nil {
opts.status("Seeding index from sibling worktree...")
}

seed := opts.seed
if seed == nil {
seed = index.SeedFromDonorContext
}
seeded, err := seed(ctx, donorPath, dbPath, projectPath)
if err != nil {
logger.Warn("seed from donor worktree failed",
"project_path", projectPath,
"donor_path", donorPath,
"error", err,
)
warning := fmt.Sprintf("index seeded from scratch (sibling copy failed: %v)", err)
if opts.status != nil {
opts.status(fmt.Sprintf("Sibling index copy failed: %v; indexing from scratch.", err))
}
return warning
}
if seeded && opts.status != nil {
opts.status("Seeded index from sibling worktree.")
} else if opts.status != nil {
if _, statErr := os.Stat(dbPath); statErr == nil {
opts.status("Index was seeded by another process.")
} else {
opts.status("Sibling index could not be reused; indexing from scratch.")
}
}
return ""
}
126 changes: 126 additions & 0 deletions cmd/seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
)

func seedTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

func TestSeedFromDonorIfNew(t *testing.T) {
tests := []struct {
name string
setupDB bool // pre-create the destination DB
donor string
seedErr error // error returned by the seed stub
wantFind bool // donor discovery should run
wantSeed bool // seed should run
}{
{
name: "skips when DB already exists",
setupDB: true,
donor: "/donor.db",
wantFind: false,
wantSeed: false,
},
{
name: "seeds when DB missing and donor found",
donor: "/donor.db",
wantFind: true,
wantSeed: true,
},
{
name: "no seed when no donor found",
donor: "",
wantFind: true,
wantSeed: false,
},
{
name: "seed error is swallowed",
donor: "/donor.db",
seedErr: errors.New("copy failed"),
wantFind: true,
wantSeed: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "index.db")
if tt.setupDB {
if err := os.WriteFile(dbPath, []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
}

var findCalled, seedCalled bool
var gotDonor, gotDst string
var statuses []string
warning := seedFromDonorIfNew(
context.Background(),
dbPath,
"/project",
"model",
seedTestLogger(),
seedOptions{
findDonor: func(_, _ string) string {
findCalled = true
return tt.donor
},
seed: func(_ context.Context, donor, dst, projectPath string) (bool, error) {
seedCalled = true
gotDonor, gotDst = donor, dst
if projectPath != "/project" {
t.Errorf("seed project path = %q, want /project", projectPath)
}
return tt.seedErr == nil, tt.seedErr
},
status: func(message string) {
statuses = append(statuses, message)
},
},
)

if findCalled != tt.wantFind {
t.Errorf("donor discovery called = %v, want %v", findCalled, tt.wantFind)
}
if seedCalled != tt.wantSeed {
t.Errorf("seed called = %v, want %v", seedCalled, tt.wantSeed)
}
if tt.wantSeed && (gotDonor != tt.donor || gotDst != dbPath) {
t.Errorf("seed called with (%q, %q), want (%q, %q)", gotDonor, gotDst, tt.donor, dbPath)
}
if tt.seedErr != nil {
if warning == "" {
t.Error("expected warning when seed fails")
}
if len(statuses) != 2 {
t.Fatalf("expected start and failure status, got %v", statuses)
}
} else if warning != "" {
t.Errorf("unexpected warning: %q", warning)
}
})
}
}
33 changes: 9 additions & 24 deletions cmd/stdio.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ type indexerCache struct {
embedTimeout time.Duration // override for tests; 0 means defaultEmbedTimeout
staleEmbedTimeout time.Duration // override for tests; 0 means defaultStaleEmbedTimeout
findDonorFunc func(string, string) string // nil uses config.FindDonorIndex
seedFunc func(string, string) (bool, error) // nil uses index.SeedFromDonor
seedFunc func(context.Context, string, string, string) (bool, error) // nil uses index.SeedFromDonorContext
ensureFreshFunc func(ctx context.Context, idx *index.Indexer, projectDir string, progress index.ProgressFunc) (bool, index.Stats, error) // nil uses idx.EnsureFresh
log *slog.Logger
wg sync.WaitGroup // tracks background reindex goroutines
Expand Down Expand Up @@ -484,7 +484,6 @@ func (ic *indexerCache) getOrCreate(projectPath string, preferredRoot string, mo
}

// Seed from sibling worktree if this is a new index.
var seedWarning string
isNewDB := false
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
isNewDB = true
Expand All @@ -494,29 +493,15 @@ func (ic *indexerCache) getOrCreate(projectPath string, preferredRoot string, mo
"model", modelName,
"index_version", config.IndexVersion,
)
findDonor := ic.findDonorFunc
if findDonor == nil {
findDonor = config.FindDonorIndex
}
if donorPath := findDonor(effectiveRoot, modelName); donorPath != "" {
ic.logger().Info("seeding index from donor worktree",
"effective_root", effectiveRoot,
"donor_path", donorPath,
)
seedFn := ic.seedFunc
if seedFn == nil {
seedFn = index.SeedFromDonor
}
if _, seedErr := seedFn(donorPath, dbPath); seedErr != nil {
ic.logger().Warn("seed from donor worktree failed",
"effective_root", effectiveRoot,
"donor_path", donorPath,
"error", seedErr,
)
seedWarning = fmt.Sprintf("index seeded from scratch (sibling copy failed: %v)", seedErr)
}
}
}
seedCtx := ic.closeCtx
if seedCtx == nil {
seedCtx = context.Background()
}
seedWarning := seedFromDonorIfNew(seedCtx, dbPath, effectiveRoot, modelName, ic.logger(), seedOptions{
findDonor: ic.findDonorFunc,
seed: ic.seedFunc,
})

idx, err := index.NewIndexer(dbPath, ic.embedder, ic.cfg.MaxChunkTokens())
if err != nil {
Expand Down
Loading
Loading