Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
15c5d39
fix(index): purge stale unsupported-extension records from donor seed…
aeneasr Mar 21, 2026
499ce2e
test(index): add regression tests for stale-extension purge + actuall…
aeneasr Mar 21, 2026
e18a0c8
fix: remove worktrees
aeneasr Mar 21, 2026
66eae53
feat(indexlock): add flock-based advisory lock for index coordination
aeneasr Mar 21, 2026
1ea436c
feat(index): acquire flock before indexing, thread ctx, cancel on SIG…
aeneasr Mar 21, 2026
7d4775e
fix(index): write skip message to stderr, document signal-race trade-off
aeneasr Mar 21, 2026
c02b2b4
feat(hook): spawn detached background indexer on session start
aeneasr Mar 21, 2026
a56c3bf
fix(hook): add license headers, smoke test, and Windows stub guidance
aeneasr Mar 21, 2026
3d424db
feat(stdio): skip EnsureFresh when background indexer holds lock
aeneasr Mar 21, 2026
ea88008
refactor(stdio): thread dbPath into ensureIndexed; test lock-held fas…
aeneasr Mar 21, 2026
75e109b
docs: note Windows is not supported (flock dependency)
aeneasr Mar 21, 2026
4bb0d01
fix(tui): disable ShowElapsedTime to prevent pterm ticker goroutine race
aeneasr Mar 21, 2026
00ab4e3
fix(log): use lumberjack for log rotation; remove repro test; fix imp…
aeneasr Mar 21, 2026
952a321
refactor(indexlock): migrate to gofrs/flock for cross-platform locking
aeneasr Mar 21, 2026
833481a
fix(stdio): reuse parent index when search path points into internal …
aeneasr Mar 21, 2026
c170140
fix(index): defer UpsertFile until chunks are flushed
aeneasr Mar 21, 2026
46d49e9
fix(index): force reindex removes deleted files and purges stale exte…
aeneasr Mar 21, 2026
476b6e1
fix(store): increase busy_timeout to 30s for slow embedding batches
aeneasr Mar 21, 2026
b01305b
fix(store): wrap dimension-reset deletes in a transaction
aeneasr Mar 21, 2026
49d970d
docs(indexlock): fix IsHeld comment to match fail-closed behavior
aeneasr Mar 21, 2026
6bf0664
fix(stdio): skip force reindex when background indexer holds lock
aeneasr Mar 21, 2026
8dd415c
fix(cmd): close indexers on shutdown and set up signals before DB open
aeneasr Mar 21, 2026
113b4fd
fix(merkle): skip symlinks and files >10MB during tree walk
aeneasr Mar 21, 2026
ee9d71c
fix(index): skip binary files based on NUL-byte detection
aeneasr Mar 21, 2026
d7284b4
fix(index): simplify isBinaryContent using slices.Contains
aeneasr Mar 21, 2026
1a1ddcd
fix(chunker): cap leading comments to 10 lines
aeneasr Mar 21, 2026
9f7d462
fix(chunker): deduplicate overlapping tree-sitter chunks
aeneasr Mar 21, 2026
adf44fe
feat(merkle): support linguist-vendored in .gitattributes
aeneasr Mar 21, 2026
1dad39a
feat(merkle): load global gitignore (core.excludesFile)
aeneasr Mar 21, 2026
9861dce
fix(stdio): increase scanner buffer to 1MB for long lines
aeneasr Mar 21, 2026
4b78043
refactor(index): switch to RWMutex for consistent locking discipline
aeneasr Mar 21, 2026
27ecba7
fix(lint): wrap deferred Close calls to satisfy errcheck
aeneasr Mar 21, 2026
314e2ac
fix(cmd): remove lumberjack rotation; add discardLog to test fixtures
aeneasr Mar 21, 2026
4a5caa4
chore: rebase onto main, remove unused ristretto dependency
aeneasr Mar 21, 2026
14fe55f
fix(stdio): propagate LUMEN_FRESHNESS_TTL from config to indexerCache
aeneasr Mar 22, 2026
024b28f
test(e2e): update lang snapshots for new chunker + fix CI timeout
aeneasr Mar 22, 2026
51a7865
ci: add -v to unit test command to prevent 10-min output-stall kill
aeneasr Mar 22, 2026
3a12d17
ci: add heartbeat to survive silent CGO compilation in Test job
aeneasr Mar 22, 2026
a35f7a4
ci: remove -v from test command — floods pipe buffer, kills heartbeat
aeneasr Mar 22, 2026
8684111
ci: run test packages sequentially (-p 1) to avoid SQLite I/O contention
aeneasr Mar 22, 2026
c913c2b
ci: add ccache + timeout-minutes to fix CGO compilation stall
aeneasr Mar 22, 2026
06630eb
fix(cmd): add TestMain to prevent fork-bomb in TestSpawnBackgroundInd…
aeneasr Mar 22, 2026
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
1 change: 0 additions & 1 deletion .claude/worktrees/feat/nomic-embed-code-7b
Submodule nomic-embed-code-7b deleted from 5cb827
21 changes: 18 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

Expand All @@ -18,10 +19,24 @@ jobs:
go-version: '1.26'
cache: true

- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ runner.os }}-go-cgo
max-size: 500M

- name: Test
run:
CGO_ENABLED=1 go test -tags=fts5 -coverprofile=coverage.out
-covermode=atomic ./...
run: |
# Use ccache to speed up repeated CGO (C) compilation of sqlite3/tree-sitter.
export CC="ccache gcc"
export CXX="ccache g++"
# Print a heartbeat every 60 s so GitHub Actions does not kill the
# runner during the silent CGO (sqlite3) compilation phase.
(while true; do echo "[ci] tests still running…"; sleep 60; done) &
HEARTBEAT=$!
trap "kill $HEARTBEAT 2>/dev/null || true" EXIT
CGO_ENABLED=1 go test -p 1 -tags=fts5 -timeout=30m \
-coverprofile=coverage.out -covermode=atomic ./...

- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ dist/
.DS_Store

# Claude
.claude/*.local.json
**/.claude/*.local.json
.worktrees/

lumen

bench-swe/bench-swe
bench-swe/overview.txt

**/.claude/worktrees
**/.claire
.worktrees
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ _Claude Code asking about the

**Prerequisites:**

> **Platform support:** Linux, macOS, and Windows. File locking for background
> indexing coordination uses `flock(2)` on Unix and `LockFileEx` on Windows
> (via [gofrs/flock](https://github.com/gofrs/flock)).

1. [Ollama](https://ollama.com/) installed and running, then pull the default
embedding model:
```bash
Expand Down
17 changes: 4 additions & 13 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

Expand Down Expand Up @@ -80,6 +79,10 @@ func runHookSessionStart(_ *cobra.Command, args []string) error {
cwd, _ = os.Getwd()
}

// Kick off a background incremental re-index so the index is fresh
// by the time the first semantic_search arrives.
spawnBackgroundIndexer(cwd)

content := generateSessionContext(mcpName, cwd)

out := hookOutput{
Expand Down Expand Up @@ -150,18 +153,6 @@ func generateSessionContextInternal(mcpName, cwd string, findDonor func(string,
return sb.String()
}

// spawnBackgroundIndexer runs "lumen index <cwd>" as a detached background
// process. Errors are silently ignored — the MCP server will index on demand
// if pre-warming fails or hasn't finished by the time the first search arrives.
func spawnBackgroundIndexer(cwd string) {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "index", cwd)
_ = cmd.Start()
}

// --- PreToolUse hook ---

var hookPreToolUseCmd = &cobra.Command{
Expand Down
56 changes: 56 additions & 0 deletions cmd/hook_spawn_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//go:build !windows

// 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 (
"os"
"os/exec"
"path/filepath"
"syscall"

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

// spawnBackgroundIndexer launches "lumen index <projectPath>" as a fully
// detached background process (new session via Setsid). The spawned process
// acquires an advisory flock before indexing, so concurrent calls from
// multiple Claude terminals are safe — only one indexer runs at a time.
//
// Errors are silently ignored: background indexing is best-effort. If it
// fails, the MCP server falls back to its normal lazy EnsureFresh path.
func spawnBackgroundIndexer(projectPath string) {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "index", projectPath)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Stdout = nil

logPath := filepath.Join(config.XDGDataDir(), "lumen", "debug.log")
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stderr = f
defer func() { _ = f.Close() }()
}

if err := cmd.Start(); err != nil {
return
}
// Reap the child to avoid a zombie process entry. The goroutine exits
// when the detached indexer terminates (or immediately if Start failed).
go func() { _ = cmd.Wait() }()
}
56 changes: 56 additions & 0 deletions cmd/hook_spawn_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//go:build windows

// 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 (
"os"
"os/exec"
"path/filepath"
"syscall"

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

// spawnBackgroundIndexer launches "lumen index <projectPath>" as a detached
// background process on Windows using CREATE_NEW_PROCESS_GROUP and
// DETACHED_PROCESS flags. The spawned process acquires an advisory lock
// (via LockFileEx) before indexing, so concurrent calls are safe.
//
// Errors are silently ignored: background indexing is best-effort.
func spawnBackgroundIndexer(projectPath string) {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "index", projectPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | 0x00000008, // DETACHED_PROCESS
}
cmd.Stdout = nil

logPath := filepath.Join(config.XDGDataDir(), "lumen", "debug.log")
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stderr = f
defer f.Close()
}

if err := cmd.Start(); err != nil {
return
}
// Reap the child to avoid resource leaks.
go func() { _ = cmd.Wait() }()
}
24 changes: 24 additions & 0 deletions cmd/hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ import (
"github.com/ory/lumen/internal/config"
)

// TestMain detects when the cmd test binary is invoked as a background
// indexer subprocess (via spawnBackgroundIndexer → os.Executable()) and exits
// immediately instead of running the full test suite. Without this guard,
// TestSpawnBackgroundIndexer_DoesNotPanic would create a fork-bomb: the
// spawned test binary runs all tests, which spawns more binaries, etc.
func TestMain(m *testing.M) {
// spawnBackgroundIndexer calls: exec.Command(exe, "index", projectPath)
// where exe == os.Executable() == this test binary.
// Detect that pattern and exit cleanly so no tests run in the subprocess.
if len(os.Args) > 1 && os.Args[1] == "index" {
os.Exit(0)
}
os.Exit(m.Run())
}

func TestGenerateSessionContext_NoIndex(t *testing.T) {
// Use the internal version with a no-op bgIndexer to avoid spawning the
// test binary as a background process (which would trigger a fork bomb:
Expand Down Expand Up @@ -283,3 +298,12 @@ func TestHookOutputJSON(t *testing.T) {
t.Error("additionalContext should contain tool reference")
}
}

// TestSpawnBackgroundIndexer_DoesNotPanic verifies that spawnBackgroundIndexer
// does not panic or block on a path that contains no indexable files.
// The spawned process will acquire the lock, find nothing to index, and exit.
func TestSpawnBackgroundIndexer_DoesNotPanic(t *testing.T) {
// Use a temp directory — no Go files, so the indexer exits quickly.
spawnBackgroundIndexer(t.TempDir())
// If we reach here without panic or deadlock, the test passes.
}
60 changes: 40 additions & 20 deletions cmd/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

"github.com/ory/lumen/internal/config"
"github.com/ory/lumen/internal/embedder"
"github.com/ory/lumen/internal/index"
"github.com/ory/lumen/internal/indexlock"
"github.com/ory/lumen/internal/tui"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -56,17 +59,47 @@ func runIndex(cmd *cobra.Command, args []string) error {
return fmt.Errorf("resolve path: %w", err)
}

idx, err := setupIndexer(&cfg, projectPath)
dbPath := config.DBPathForProject(projectPath, cfg.Model)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return fmt.Errorf("create db directory: %w", err)
}

lockPath := indexlock.LockPathForDB(dbPath)
lock, err := indexlock.TryAcquire(lockPath)
if err != nil {
return fmt.Errorf("acquire index lock: %w", err)
}
if lock == nil {
// Another indexer is already running for this project — skip silently.
// This is the normal case when multiple Claude terminals are open.
fmt.Fprintln(os.Stderr, "Another indexer is already running for this project. Skipping.")
return nil
}
defer lock.Release()

// Cancel context on SIGTERM or SIGINT so the indexer stops cleanly and
// the deferred lock.Release() runs before exit.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

idx, err := setupIndexer(&cfg, dbPath)
if err != nil {
return err
}
defer func() { _ = idx.Close() }()

p := tui.NewProgress(os.Stderr)
p.Info(fmt.Sprintf("Indexing %s (model: %s, dims: %d)", projectPath, cfg.Model, cfg.Dims))

start := time.Now()
stats, err := performIndexing(cmd, idx, projectPath, p)
stats, err := performIndexing(ctx, cmd, idx, projectPath, p)
if err != nil {
if ctx.Err() != nil {
// A signal arrived; treat as clean exit. If an unrelated error
// also occurred in the same instant, it is intentionally dropped —
// the cancellation is the primary cause and the lock will be released.
return nil
}
return err
}

Expand All @@ -90,43 +123,30 @@ func applyModelFlag(cmd *cobra.Command, cfg *config.Config) error {
return nil
}

func setupIndexer(cfg *config.Config, projectPath string) (*index.Indexer, error) {
// setupIndexer receives dbPath so it is computed exactly once in runIndex.
func setupIndexer(cfg *config.Config, dbPath string) (*index.Indexer, error) {
emb, err := newEmbedder(*cfg)
if err != nil {
return nil, fmt.Errorf("create embedder: %w", err)
}

dbPath := config.DBPathForProject(projectPath, cfg.Model)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}

// Seed from a sibling worktree index if this is a brand-new index.
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
if donorPath := config.FindDonorIndex(projectPath, cfg.Model); donorPath != "" {
if _, seedErr := index.SeedFromDonor(donorPath, dbPath); seedErr != nil {
_, _ = fmt.Fprintf(os.Stderr, "lumen: seed from worktree failed: %v\n", seedErr)
}
}
}

idx, err := index.NewIndexer(dbPath, emb, cfg.MaxChunkTokens)
if err != nil {
return nil, fmt.Errorf("create indexer: %w", err)
}
return idx, nil
}

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

progress := p.AsProgressFunc()

if force {
return idx.Index(context.Background(), projectPath, true, progress)
return idx.Index(ctx, projectPath, true, progress)
}

reindexed, stats, err := idx.EnsureFresh(context.Background(), projectPath, progress)
reindexed, stats, err := idx.EnsureFresh(ctx, projectPath, progress)
if err != nil {
return stats, fmt.Errorf("indexing: %w", err)
}
Expand Down
Loading
Loading