Roo-Code/src/services/code-index/orchestrator.ts
James Mtendamema 67ea856fd0
feat: add per-workspace indexing opt-in and stop/cancel control (#11456)
* feat: add per-workspace indexing opt-in and stop/cancel control

- Add codeIndexWorkspaceEnabled flag in workspaceState (default: false)
- Thread AbortController/AbortSignal through orchestrator → scanner
- Add Stop Indexing button and Stopping state to UI
- Fix handleSettingsChange() to abort active scan when disabling toggle
- Add translations for all 18 locales

* fix: correct abort handling in indexing scanner and orchestrator

- Re-throw AbortError in scanner's file processing catch block to prevent
  abort signals from being silently swallowed as file errors
- Reorder stopWatcher() before setSystemState() in orchestrator abort
  catch path to ensure watcher cleanup before state transition
- Update scanner test to assert AbortError propagation on mid-scan abort

* fix: optimize workspace check ordering, translate new i18n keys, fix abort handling

- Move workspace-enabled check before _recreateServices() in initialize()
  to avoid creating Qdrant/embedder connections for disabled workspaces
- Translate new i18n keys (indexingStopped, indexingStoppedPartial, stopping,
  stopIndexingButton, stoppingButton, workspaceToggleLabel,
  workspaceDisabledMessage) in all 17 non-English locales
- Re-throw AbortError in scanner catch block to prevent silent swallowing
- Reorder stopWatcher() before setSystemState() in orchestrator abort path
- Update scanner test to assert AbortError propagation on mid-scan abort
- Fix recoverFromError test for workspace-enabled check ordering

* fix: per-folder enablement key, abort-safe dispose and back-pressure, translate i18n

Addresses 0xMink review feedback:
- Store workspace enablement keyed by folder path to support multi-root
  workspaces (codeIndexWorkspaceEnabled:<path> instead of single boolean)
- Add test proving folder A enabled does not enable folder B
- dispose() now calls stopIndexing() to abort orphaned scans on folder removal
- Scanner back-pressure loop checks abort signal to avoid spin-waiting
- Move workspace-enabled check before _recreateServices() in initialize()
- Translate new i18n keys in all 17 non-English locales
- Fix abort handling in orchestrator and scanner catch blocks

* fix: flush debounced cache writes on abort to preserve indexing progress

* feat: add global auto-enable default for backward-compatible workspace indexing

* fix: stop/start indexer when auto-enable default changes effective state

* fix: URI-keyed enablement, throw AbortError in back-pressure, stopWatcher on early-return

* fix: iterate all managers when auto-enable default changes in multi-root workspaces

---------

Co-authored-by: James Mtendamema <jmtendamema@geologicai.com>
2026-02-18 23:09:17 -07:00

428 lines
15 KiB
TypeScript

import * as vscode from "vscode"
import * as path from "path"
import { CodeIndexConfigManager } from "./config-manager"
import { CodeIndexStateManager, IndexingState } from "./state-manager"
import { IFileWatcher, IVectorStore, BatchProcessingSummary } from "./interfaces"
import { DirectoryScanner } from "./processors"
import { CacheManager } from "./cache-manager"
import { TelemetryService } from "@roo-code/telemetry"
import { TelemetryEventName } from "@roo-code/types"
import { t } from "../../i18n"
/**
* Manages the code indexing workflow, coordinating between different services and managers.
*/
export class CodeIndexOrchestrator {
private _fileWatcherSubscriptions: vscode.Disposable[] = []
private _isProcessing: boolean = false
private _abortController: AbortController | null = null
constructor(
private readonly configManager: CodeIndexConfigManager,
private readonly stateManager: CodeIndexStateManager,
private readonly workspacePath: string,
private readonly cacheManager: CacheManager,
private readonly vectorStore: IVectorStore,
private readonly scanner: DirectoryScanner,
private readonly fileWatcher: IFileWatcher,
) {}
/**
* Starts the file watcher if not already running.
*/
private async _startWatcher(): Promise<void> {
if (!this.configManager.isFeatureConfigured) {
throw new Error("Cannot start watcher: Service not configured.")
}
this.stateManager.setSystemState("Indexing", "Initializing file watcher...")
try {
await this.fileWatcher.initialize()
this._fileWatcherSubscriptions = [
this.fileWatcher.onDidStartBatchProcessing((filePaths: string[]) => {}),
this.fileWatcher.onBatchProgressUpdate(({ processedInBatch, totalInBatch, currentFile }) => {
if (totalInBatch > 0 && this.stateManager.state !== "Indexing") {
this.stateManager.setSystemState("Indexing", "Processing file changes...")
}
this.stateManager.reportFileQueueProgress(
processedInBatch,
totalInBatch,
currentFile ? path.basename(currentFile) : undefined,
)
if (processedInBatch === totalInBatch) {
// Covers (N/N) and (0/0)
if (totalInBatch > 0) {
// Batch with items completed
this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.")
} else {
if (this.stateManager.state === "Indexing") {
// Only transition if it was "Indexing"
this.stateManager.setSystemState("Indexed", "Index up-to-date. File queue empty.")
}
}
}
}),
this.fileWatcher.onDidFinishBatchProcessing((summary: BatchProcessingSummary) => {
if (summary.batchError) {
console.error(`[CodeIndexOrchestrator] Batch processing failed:`, summary.batchError)
} else {
const successCount = summary.processedFiles.filter(
(f: { status: string }) => f.status === "success",
).length
const errorCount = summary.processedFiles.filter(
(f: { status: string }) => f.status === "error" || f.status === "local_error",
).length
}
}),
]
} catch (error) {
console.error("[CodeIndexOrchestrator] Failed to start file watcher:", error)
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "_startWatcher",
})
throw error
}
}
/**
* Updates the status of a file in the state manager.
*/
/**
* Initiates the indexing process (initial scan and starts watcher).
*/
public async startIndexing(): Promise<void> {
// Check if workspace is available first
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
this.stateManager.setSystemState("Error", t("embeddings:orchestrator.indexingRequiresWorkspace"))
console.warn("[CodeIndexOrchestrator] Start rejected: No workspace folder open.")
return
}
if (!this.configManager.isFeatureConfigured) {
this.stateManager.setSystemState("Standby", "Missing configuration. Save your settings to start indexing.")
console.warn("[CodeIndexOrchestrator] Start rejected: Missing configuration.")
return
}
if (
this._isProcessing ||
(this.stateManager.state !== "Standby" &&
this.stateManager.state !== "Error" &&
this.stateManager.state !== "Indexed")
) {
console.warn(
`[CodeIndexOrchestrator] Start rejected: Already processing or in state ${this.stateManager.state}.`,
)
return
}
this._isProcessing = true
this._abortController = new AbortController()
const signal = this._abortController.signal
this.stateManager.setSystemState("Indexing", "Initializing services...")
// Track whether we successfully connected to Qdrant and started indexing
// This helps us decide whether to preserve cache on error
let indexingStarted = false
try {
const collectionCreated = await this.vectorStore.initialize()
// Successfully connected to Qdrant
indexingStarted = true
if (collectionCreated) {
await this.cacheManager.clearCacheFile()
}
// Check if the collection already has indexed data
// If it does, we can skip the full scan and just start the watcher
const hasExistingData = await this.vectorStore.hasIndexedData()
if (hasExistingData && !collectionCreated) {
// Collection exists with data - run incremental scan to catch any new/changed files
// This handles files added while workspace was closed or Qdrant was inactive
console.log(
"[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...",
)
this.stateManager.setSystemState("Indexing", "Checking for new or modified files...")
// Mark as incomplete at the start of incremental scan
await this.vectorStore.markIndexingIncomplete()
let cumulativeBlocksIndexed = 0
let cumulativeBlocksFoundSoFar = 0
let batchErrors: Error[] = []
const handleFileParsed = (fileBlockCount: number) => {
cumulativeBlocksFoundSoFar += fileBlockCount
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
}
const handleBlocksIndexed = (indexedCount: number) => {
cumulativeBlocksIndexed += indexedCount
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
}
// Run incremental scan - scanner will skip unchanged files using cache
const result = await this.scanner.scanDirectory(
this.workspacePath,
(batchError: Error) => {
console.error(
`[CodeIndexOrchestrator] Error during incremental scan batch: ${batchError.message}`,
batchError,
)
batchErrors.push(batchError)
},
handleBlocksIndexed,
handleFileParsed,
signal,
)
if (signal.aborted) {
await this.cacheManager.flush()
this.stopWatcher()
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
return
}
if (!result) {
throw new Error("Incremental scan failed, is scanner initialized?")
}
// If new files were found and indexed, log the results
if (cumulativeBlocksFoundSoFar > 0) {
console.log(
`[CodeIndexOrchestrator] Incremental scan completed: ${cumulativeBlocksIndexed} blocks indexed from new/changed files`,
)
} else {
console.log("[CodeIndexOrchestrator] No new or changed files found")
}
await this._startWatcher()
// Mark indexing as complete after successful incremental scan
await this.vectorStore.markIndexingComplete()
this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted"))
} else {
// No existing data or collection was just created - do a full scan
this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...")
// Mark as incomplete at the start of full scan
await this.vectorStore.markIndexingIncomplete()
let cumulativeBlocksIndexed = 0
let cumulativeBlocksFoundSoFar = 0
let batchErrors: Error[] = []
const handleFileParsed = (fileBlockCount: number) => {
cumulativeBlocksFoundSoFar += fileBlockCount
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
}
const handleBlocksIndexed = (indexedCount: number) => {
cumulativeBlocksIndexed += indexedCount
this.stateManager.reportBlockIndexingProgress(cumulativeBlocksIndexed, cumulativeBlocksFoundSoFar)
}
const result = await this.scanner.scanDirectory(
this.workspacePath,
(batchError: Error) => {
console.error(
`[CodeIndexOrchestrator] Error during initial scan batch: ${batchError.message}`,
batchError,
)
batchErrors.push(batchError)
},
handleBlocksIndexed,
handleFileParsed,
signal,
)
if (signal.aborted) {
await this.cacheManager.flush()
this.stopWatcher()
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
return
}
if (!result) {
throw new Error("Scan failed, is scanner initialized?")
}
const { stats } = result
// Check if any blocks were actually indexed successfully
// If no blocks were indexed but blocks were found, it means all batches failed
if (cumulativeBlocksIndexed === 0 && cumulativeBlocksFoundSoFar > 0) {
if (batchErrors.length > 0) {
// Use the first batch error as it's likely representative of the main issue
const firstError = batchErrors[0]
throw new Error(`Indexing failed: ${firstError.message}`)
} else {
throw new Error(t("embeddings:orchestrator.indexingFailedNoBlocks"))
}
}
// Check for partial failures - if a significant portion of blocks failed
const failureRate = (cumulativeBlocksFoundSoFar - cumulativeBlocksIndexed) / cumulativeBlocksFoundSoFar
if (batchErrors.length > 0 && failureRate > 0.1) {
// More than 10% of blocks failed to index
const firstError = batchErrors[0]
throw new Error(
`Indexing partially failed: Only ${cumulativeBlocksIndexed} of ${cumulativeBlocksFoundSoFar} blocks were indexed. ${firstError.message}`,
)
}
// CRITICAL: If there were ANY batch errors and NO blocks were successfully indexed,
// this is a complete failure regardless of the failure rate calculation
if (batchErrors.length > 0 && cumulativeBlocksIndexed === 0) {
const firstError = batchErrors[0]
throw new Error(`Indexing failed completely: ${firstError.message}`)
}
// Final sanity check: If we found blocks but indexed none and somehow no errors were reported,
// this is still a failure
if (cumulativeBlocksFoundSoFar > 0 && cumulativeBlocksIndexed === 0) {
throw new Error(t("embeddings:orchestrator.indexingFailedCritical"))
}
await this._startWatcher()
// Mark indexing as complete after successful full scan
await this.vectorStore.markIndexingComplete()
this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted"))
}
} catch (error: any) {
// Handle abort gracefully — not an error, just a user-initiated stop
if (error?.name === "AbortError" || signal.aborted) {
console.log("[CodeIndexOrchestrator] Indexing aborted by user.")
await this.cacheManager.flush()
this.stopWatcher()
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped"))
return
}
console.error("[CodeIndexOrchestrator] Error during indexing:", error)
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "startIndexing",
})
if (indexingStarted) {
try {
await this.vectorStore.clearCollection()
} catch (cleanupError) {
console.error("[CodeIndexOrchestrator] Failed to clean up after error:", cleanupError)
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
stack: cleanupError instanceof Error ? cleanupError.stack : undefined,
location: "startIndexing.cleanup",
})
}
}
// Only clear cache if indexing had started (Qdrant connection succeeded)
// If we never connected to Qdrant, preserve cache for incremental scan when it comes back
if (indexingStarted) {
// Indexing started but failed mid-way - clear cache to avoid cache-Qdrant mismatch
await this.cacheManager.clearCacheFile()
console.log(
"[CodeIndexOrchestrator] Indexing failed after starting. Clearing cache to avoid inconsistency.",
)
} else {
// Never connected to Qdrant - preserve cache for future incremental scan
console.log(
"[CodeIndexOrchestrator] Failed to connect to Qdrant. Preserving cache for future incremental scan.",
)
}
this.stateManager.setSystemState(
"Error",
t("embeddings:orchestrator.failedDuringInitialScan", {
errorMessage: error.message || t("embeddings:orchestrator.unknownError"),
}),
)
this.stopWatcher()
} finally {
this._isProcessing = false
this._abortController = null
}
}
/**
* Stops any in-progress indexing by aborting the scan and stopping the file watcher.
*/
public stopIndexing(): void {
if (this._abortController) {
this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial"))
this._abortController.abort()
this._abortController = null
}
this.stopWatcher()
}
/**
* Stops the file watcher and cleans up resources.
*/
public stopWatcher(): void {
this.fileWatcher.dispose()
this._fileWatcherSubscriptions.forEach((sub) => sub.dispose())
this._fileWatcherSubscriptions = []
if (this.stateManager.state !== "Error" && this.stateManager.state !== "Stopping") {
this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.fileWatcherStopped"))
}
this._isProcessing = false
}
/**
* Clears all index data by stopping the watcher, clearing the vector store,
* and resetting the cache file.
*/
public async clearIndexData(): Promise<void> {
this._isProcessing = true
try {
await this.stopWatcher()
try {
if (this.configManager.isFeatureConfigured) {
await this.vectorStore.deleteCollection()
} else {
console.warn("[CodeIndexOrchestrator] Service not configured, skipping vector collection clear.")
}
} catch (error: any) {
console.error("[CodeIndexOrchestrator] Failed to clear vector collection:", error)
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "clearIndexData",
})
this.stateManager.setSystemState("Error", `Failed to clear vector collection: ${error.message}`)
}
await this.cacheManager.clearCacheFile()
if (this.stateManager.state !== "Error") {
this.stateManager.setSystemState("Standby", "Index data cleared successfully.")
}
} finally {
this._isProcessing = false
}
}
/**
* Gets the current state of the indexing system.
*/
public get state(): IndexingState {
return this.stateManager.state
}
}