Skip to content

Commit 0270ad6

Browse files
author
razvan
committed
feat: non-blocking search during indexing + AST fallback
- SearchCode no longer blocks with ErrIndexingStarted when partial collections exist — fan-out searches all available languages - HybridSearchCode returns nil instead of blocking, letting SmartSearch use semantic results from the parallel fan-out - New FallbackDirectSearch: when zero Qdrant collections exist, walks the workspace filesystem, parses files with AST parsers, and scores symbols with multi-signal lexical matching (name/sig/content/doc) - SmartSearch explicitly tells AI agents when results come from fallback with clear warnings about limitations and indexing progress - 16 new tests covering all scenarios (non-blocking, fallback, scoring)
1 parent d4a55af commit 0270ad6

6 files changed

Lines changed: 1054 additions & 23 deletions

File tree

internal/service/engine/engine.go

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,6 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
352352
primaryLang = a.Name()
353353
}
354354

355-
// Ensure at least the primary collection exists before embedding (fast fail + indexing trigger)
356355
primaryColl := wctx.CollectionName(primaryLang)
357356
t1 := time.Now()
358357

@@ -369,25 +368,33 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
369368
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
370369
}
371370
}
372-
logger.Instance.Info("[IDX] ws=%s Indexing in progress (%d%%) — searching available results", filepath.Base(wctx.Root), idxStatus.GlobalPercent)
371+
logger.Instance.Info("[IDX] ws=%s Indexing in progress (%d%%) — will search available collections", filepath.Base(wctx.Root), idxStatus.GlobalPercent)
373372
}
374373
}
375-
exists, err := e.search.CollectionExists(ctx, primaryColl)
374+
375+
// Check if the primary collection exists.
376+
// If not, trigger background indexing but do NOT block — the fan-out below
377+
// will search any other language collections that do exist.
378+
primaryExists, err := e.search.CollectionExists(ctx, primaryColl)
376379
logger.Instance.Debug("[TIMER] SearchCode collection_exists_primary=%v (cached=%v)", time.Since(t1), time.Since(t1) < time.Millisecond)
377380
if err != nil {
378381
return nil, fmt.Errorf("failed to check collection: %w", err)
379382
}
380-
if !exists {
381-
if _, ok := e.indexingJobs.Load(wctx.ID); ok {
382-
return nil, &ErrIndexingInProgress{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
383-
}
384-
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
385-
return nil, &ErrIndexingStarted{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
383+
384+
needsTriggerIndexing := false
385+
if !primaryExists {
386+
needsTriggerIndexing = true
386387
}
387388

388389
if wctx.ReindexRequired {
389390
logger.Instance.Info("[IDX] Git state change detected (Head: %s), triggering background re-indexing for %s", wctx.HeadSHA, wctx.Root)
390-
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
391+
needsTriggerIndexing = true
392+
}
393+
394+
if needsTriggerIndexing {
395+
if _, alreadyRunning := e.indexingJobs.Load(wctx.ID); !alreadyRunning {
396+
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
397+
}
391398
}
392399

393400
// Embed ONCE, fan-out to all language collections in parallel
@@ -453,6 +460,7 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
453460
var primaryResults []storage.SearchResult
454461
var otherResults []storage.SearchResult
455462
var firstErr error
463+
var existingCollections int
456464
reportLang := primaryLang
457465
reportColl := primaryColl
458466

@@ -464,6 +472,7 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
464472
}
465473
continue
466474
}
475+
existingCollections++
467476
logger.Instance.Debug("[TIMER] SearchCode lang=%s hits=%d elapsed=%v", lr.lang, len(lr.results), lr.elapsed)
468477
if lr.coll == primaryColl {
469478
primaryResults = lr.results
@@ -474,16 +483,58 @@ func (e *Engine) SearchCode(ctx context.Context, filePath, queryText string, lim
474483

475484
all := append(primaryResults, otherResults...)
476485

486+
// If no collections exist at all, the workspace is truly not indexed.
487+
// Before returning an error, try a direct AST-based fallback search.
488+
if existingCollections == 0 && len(all) == 0 {
489+
// Ensure background indexing is triggered
490+
if needsTriggerIndexing {
491+
if _, alreadyRunning := e.indexingJobs.Load(wctx.ID); !alreadyRunning {
492+
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
493+
}
494+
}
495+
496+
// Fallback: direct AST search on filesystem — no Qdrant needed
497+
fallbackResults := e.FallbackDirectSearch(ctx, wctx.Root, queryText, limit)
498+
if len(fallbackResults) > 0 {
499+
return &SearchCodeResult{
500+
Results: fallbackResults,
501+
WorkspaceRoot: wctx.Root,
502+
WorkspaceID: wctx.ID,
503+
Collection: "fallback",
504+
Language: primaryLang,
505+
MismatchRisk: wctx.MismatchRisk,
506+
DetectionSource: wctx.DetectionSource,
507+
}, nil
508+
}
509+
510+
// Fallback also empty — report indexing status
511+
if _, alreadyRunning := e.indexingJobs.Load(wctx.ID); alreadyRunning {
512+
return nil, &ErrIndexingInProgress{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
513+
}
514+
if needsTriggerIndexing {
515+
return nil, &ErrIndexingStarted{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
516+
}
517+
if firstErr != nil {
518+
return nil, fmt.Errorf("search failed: %w", firstErr)
519+
}
520+
return nil, &ErrNotIndexed{WorkspaceRoot: wctx.Root, Collection: primaryColl, Language: primaryLang}
521+
}
522+
477523
// Global sort by score descending across all language results, then cap to limit.
478524
// Without this, primary-language results always win regardless of score.
479525
sort.Slice(all, func(i, j int) bool { return all[i].Score > all[j].Score })
480526
if limit > 0 && len(all) > limit {
481527
all = all[:limit]
482528
}
483529

484-
// If nothing was found and there were errors, surface the error
485-
if len(all) == 0 && firstErr != nil {
486-
return nil, fmt.Errorf("search failed: %w", firstErr)
530+
// If vector search returned nothing but collections exist, supplement with fallback
531+
if len(all) == 0 {
532+
fallbackResults := e.FallbackDirectSearch(ctx, wctx.Root, queryText, limit)
533+
if len(fallbackResults) > 0 {
534+
all = fallbackResults
535+
} else if firstErr != nil {
536+
return nil, fmt.Errorf("search failed: %w", firstErr)
537+
}
487538
}
488539

489540
logger.Instance.Debug("[TIMER] SearchCode TOTAL=%v (detect=%v embed=%v fanout=%v)",
@@ -515,7 +566,7 @@ func (e *Engine) HybridSearchCode(ctx context.Context, filePath, queryText strin
515566

516567
collection := wctx.CollectionName(lang)
517568

518-
// Auto-resume from index_status.json (Task4): if indexing was interrupted, resume it.
569+
// Auto-resume from index_status.json: if indexing was interrupted, resume it.
519570
if idxStatus := loadIndexStatus(wctx.Root); idxStatus != nil {
520571
if idxStatus.WorkspaceID == wctx.ID && idxStatus.GlobalPercent > 0 && idxStatus.GlobalPercent < 100 && idxStatus.State == "running" {
521572
if _, ok := e.indexingJobs.Load(wctx.ID); !ok {
@@ -527,7 +578,7 @@ func (e *Engine) HybridSearchCode(ctx context.Context, filePath, queryText strin
527578
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
528579
}
529580
}
530-
logger.Instance.Info("[IDX] ws=%s Indexing in progress (%d%%) — searching available results", filepath.Base(wctx.Root), idxStatus.GlobalPercent)
581+
logger.Instance.Info("[IDX] ws=%s Indexing in progress (%d%%) — will search available collections", filepath.Base(wctx.Root), idxStatus.GlobalPercent)
531582
}
532583
}
533584

@@ -537,16 +588,21 @@ func (e *Engine) HybridSearchCode(ctx context.Context, filePath, queryText strin
537588
}
538589

539590
if !exists {
540-
if _, ok := e.indexingJobs.Load(wctx.ID); ok {
541-
return nil, &ErrIndexingInProgress{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
591+
// Trigger background indexing but do NOT block — SmartSearch runs
592+
// SearchCode (with fan-out) in parallel and will provide results
593+
// from any available language collections.
594+
if _, alreadyRunning := e.indexingJobs.Load(wctx.ID); !alreadyRunning {
595+
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
542596
}
543-
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
544-
return nil, &ErrIndexingStarted{WorkspaceRoot: wctx.Root, WorkspaceID: wctx.ID}
597+
logger.Instance.Info("[IDX] ws=%s HybridSearch: collection %s not found — returning empty (indexing in background)", filepath.Base(wctx.Root), collection)
598+
return nil, nil
545599
}
546600

547601
if wctx.ReindexRequired {
548602
logger.Instance.Info("[IDX] Git state change detected, triggering background re-indexing for ws=%s", filepath.Base(wctx.Root))
549-
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
603+
if _, alreadyRunning := e.indexingJobs.Load(wctx.ID); !alreadyRunning {
604+
e.StartIndexingAsync(wctx.Root, wctx.ID, nil, false)
605+
}
550606
}
551607

552608
results, err := e.search.HybridSearch(ctx, collection, queryText, limit)

0 commit comments

Comments
 (0)