feat: add configurable file watcher performance settings for multi-worktree optimization

Adds two new settings for file watcher performance optimization:
- codebaseIndexFileWatcherDebounceMs: Debounce delay for batching file changes (100-5000ms, default 500ms)
- codebaseIndexFileWatcherConcurrency: Concurrency limit for file processing (1-20, default 10)

These settings help reduce CPU usage when running multiple worktrees simultaneously
by allowing users to increase debounce delays and reduce concurrency limits.

Changes:
- Add new constants and defaults in constants/index.ts
- Add new fields to codebase-index types
- Update CodeIndexConfigManager to load and expose the new settings
- Update FileWatcher to use configurable values
- Update service-factory to pass settings to FileWatcher
- Add comprehensive tests for all new functionality
This commit is contained in:
Roo Code 2026-01-06 21:17:09 +00:00
parent 503f40241d
commit 605d1c8558
8 changed files with 309 additions and 7 deletions

View file

@ -12,6 +12,13 @@ export const CODEBASE_INDEX_DEFAULTS = {
MAX_SEARCH_SCORE: 1,
DEFAULT_SEARCH_MIN_SCORE: 0.4,
SEARCH_SCORE_STEP: 0.05,
// File watcher performance settings for multi-worktree optimization
DEFAULT_FILE_WATCHER_DEBOUNCE_MS: 500,
MIN_FILE_WATCHER_DEBOUNCE_MS: 100,
MAX_FILE_WATCHER_DEBOUNCE_MS: 5000,
DEFAULT_FILE_WATCHER_CONCURRENCY: 10,
MIN_FILE_WATCHER_CONCURRENCY: 1,
MAX_FILE_WATCHER_CONCURRENCY: 20,
} as const
/**
@ -50,6 +57,17 @@ export const codebaseIndexConfigSchema = z.object({
codebaseIndexBedrockProfile: z.string().optional(),
// OpenRouter specific fields
codebaseIndexOpenRouterSpecificProvider: z.string().optional(),
// File watcher performance settings for multi-worktree optimization
codebaseIndexFileWatcherDebounceMs: z
.number()
.min(CODEBASE_INDEX_DEFAULTS.MIN_FILE_WATCHER_DEBOUNCE_MS)
.max(CODEBASE_INDEX_DEFAULTS.MAX_FILE_WATCHER_DEBOUNCE_MS)
.optional(),
codebaseIndexFileWatcherConcurrency: z
.number()
.min(CODEBASE_INDEX_DEFAULTS.MIN_FILE_WATCHER_CONCURRENCY)
.max(CODEBASE_INDEX_DEFAULTS.MAX_FILE_WATCHER_CONCURRENCY)
.optional(),
})
export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>

View file

@ -1018,6 +1018,120 @@ describe("CodeIndexConfigManager", () => {
expect(maxManager.currentSearchMaxResults).toBe(200)
})
})
describe("currentFileWatcherDebounceMs", () => {
it("should return user setting when provided", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherDebounceMs: 1000, // User setting
})
await configManager.loadConfiguration()
expect(configManager.currentFileWatcherDebounceMs).toBe(1000) // User setting
})
it("should return default when no user setting", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
// No file watcher debounce setting
})
const newManager = new CodeIndexConfigManager(mockContextProxy)
await newManager.loadConfiguration()
expect(newManager.currentFileWatcherDebounceMs).toBe(500) // Default (DEFAULT_FILE_WATCHER_DEBOUNCE_MS)
})
it("should respect minimum and maximum bounds", async () => {
// Test minimum value
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherDebounceMs: 100, // Minimum allowed
})
const minManager = new CodeIndexConfigManager(mockContextProxy)
await minManager.loadConfiguration()
expect(minManager.currentFileWatcherDebounceMs).toBe(100)
// Test maximum value
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherDebounceMs: 5000, // Maximum allowed
})
const maxManager = new CodeIndexConfigManager(mockContextProxy)
await maxManager.loadConfiguration()
expect(maxManager.currentFileWatcherDebounceMs).toBe(5000)
})
})
describe("currentFileWatcherConcurrency", () => {
it("should return user setting when provided", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherConcurrency: 5, // User setting - lower for multi-worktree
})
await configManager.loadConfiguration()
expect(configManager.currentFileWatcherConcurrency).toBe(5) // User setting
})
it("should return default when no user setting", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
// No file watcher concurrency setting
})
const newManager = new CodeIndexConfigManager(mockContextProxy)
await newManager.loadConfiguration()
expect(newManager.currentFileWatcherConcurrency).toBe(10) // Default (DEFAULT_FILE_WATCHER_CONCURRENCY)
})
it("should respect minimum and maximum bounds", async () => {
// Test minimum value
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherConcurrency: 1, // Minimum allowed
})
const minManager = new CodeIndexConfigManager(mockContextProxy)
await minManager.loadConfiguration()
expect(minManager.currentFileWatcherConcurrency).toBe(1)
// Test maximum value
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
codebaseIndexFileWatcherConcurrency: 20, // Maximum allowed
})
const maxManager = new CodeIndexConfigManager(mockContextProxy)
await maxManager.loadConfiguration()
expect(maxManager.currentFileWatcherConcurrency).toBe(20)
})
})
})
describe("empty/missing API key handling", () => {

View file

@ -2,7 +2,12 @@ import { ApiHandlerOptions } from "../../shared/api"
import { ContextProxy } from "../../core/config/ContextProxy"
import { EmbedderProvider } from "./interfaces/manager"
import { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config"
import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants"
import {
DEFAULT_SEARCH_MIN_SCORE,
DEFAULT_MAX_SEARCH_RESULTS,
DEFAULT_FILE_WATCHER_DEBOUNCE_MS,
DEFAULT_FILE_WATCHER_CONCURRENCY,
} from "./constants"
import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../shared/embeddingModels"
/**
@ -26,6 +31,9 @@ export class CodeIndexConfigManager {
private qdrantApiKey?: string
private searchMinScore?: number
private searchMaxResults?: number
// File watcher performance settings for multi-worktree optimization
private fileWatcherDebounceMs?: number
private fileWatcherConcurrency?: number
constructor(private readonly contextProxy: ContextProxy) {
// Initialize with current configuration to avoid false restart triggers
@ -87,6 +95,10 @@ export class CodeIndexConfigManager {
this.searchMinScore = codebaseIndexSearchMinScore
this.searchMaxResults = codebaseIndexSearchMaxResults
// File watcher performance settings
this.fileWatcherDebounceMs = codebaseIndexConfig.codebaseIndexFileWatcherDebounceMs
this.fileWatcherConcurrency = codebaseIndexConfig.codebaseIndexFileWatcherConcurrency
// Validate and set model dimension
const rawDimension = codebaseIndexConfig.codebaseIndexEmbedderModelDimension
if (rawDimension !== undefined && rawDimension !== null) {
@ -460,6 +472,8 @@ export class CodeIndexConfigManager {
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
searchMaxResults: this.currentSearchMaxResults,
fileWatcherDebounceMs: this.currentFileWatcherDebounceMs,
fileWatcherConcurrency: this.currentFileWatcherConcurrency,
}
}
@ -541,4 +555,24 @@ export class CodeIndexConfigManager {
public get currentSearchMaxResults(): number {
return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS
}
/**
* Gets the configured file watcher debounce delay in milliseconds.
* Higher values reduce CPU usage by batching more file changes together.
* Useful for multi-worktree scenarios where multiple watchers run simultaneously.
* Returns user setting if configured, otherwise returns default (500ms).
*/
public get currentFileWatcherDebounceMs(): number {
return this.fileWatcherDebounceMs ?? DEFAULT_FILE_WATCHER_DEBOUNCE_MS
}
/**
* Gets the configured file watcher concurrency limit.
* Lower values reduce CPU usage by processing fewer files in parallel.
* Useful for multi-worktree scenarios where multiple watchers run simultaneously.
* Returns user setting if configured, otherwise returns default (10).
*/
public get currentFileWatcherConcurrency(): number {
return this.fileWatcherConcurrency ?? DEFAULT_FILE_WATCHER_CONCURRENCY
}
}

View file

@ -14,6 +14,14 @@ export const DEFAULT_MAX_SEARCH_RESULTS = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH
export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB
/**File Watcher Performance - Configurable for multi-worktree optimization */
export const DEFAULT_FILE_WATCHER_DEBOUNCE_MS = CODEBASE_INDEX_DEFAULTS.DEFAULT_FILE_WATCHER_DEBOUNCE_MS
export const MIN_FILE_WATCHER_DEBOUNCE_MS = CODEBASE_INDEX_DEFAULTS.MIN_FILE_WATCHER_DEBOUNCE_MS
export const MAX_FILE_WATCHER_DEBOUNCE_MS = CODEBASE_INDEX_DEFAULTS.MAX_FILE_WATCHER_DEBOUNCE_MS
export const DEFAULT_FILE_WATCHER_CONCURRENCY = CODEBASE_INDEX_DEFAULTS.DEFAULT_FILE_WATCHER_CONCURRENCY
export const MIN_FILE_WATCHER_CONCURRENCY = CODEBASE_INDEX_DEFAULTS.MIN_FILE_WATCHER_CONCURRENCY
export const MAX_FILE_WATCHER_CONCURRENCY = CODEBASE_INDEX_DEFAULTS.MAX_FILE_WATCHER_CONCURRENCY
/**Directory Scanner */
export const MAX_LIST_FILES_LIMIT_CODE_INDEX = 50_000
export const BATCH_SEGMENT_THRESHOLD = 60 // Number of code segments to batch for embeddings/upserts

View file

@ -21,6 +21,9 @@ export interface CodeIndexConfig {
qdrantApiKey?: string
searchMinScore?: number
searchMaxResults?: number
// File watcher performance settings for multi-worktree optimization
fileWatcherDebounceMs?: number
fileWatcherConcurrency?: number
}
/**

View file

@ -285,4 +285,99 @@ describe("FileWatcher", () => {
expect(mockWatcher.dispose).toHaveBeenCalled()
})
})
describe("configurable performance settings", () => {
it("should use default debounce delay when not specified", async () => {
// The default FileWatcher was created in beforeEach without custom parameters
// Default is 500ms from DEFAULT_FILE_WATCHER_DEBOUNCE_MS
await fileWatcher.initialize()
// Trigger a file event
await mockOnDidCreate({ fsPath: "/mock/workspace/src/file.ts" })
// Wait less than default debounce time (500ms) - batch should not have started
await new Promise((resolve) => setTimeout(resolve, 200))
// No batch processing should have happened yet
expect(mockVectorStore.upsertPoints).not.toHaveBeenCalled()
// Wait for remaining time plus buffer
await new Promise((resolve) => setTimeout(resolve, 400))
// Now batch processing should have been triggered (or completed)
})
it("should use custom debounce delay when specified", async () => {
// Create a file watcher with a longer custom debounce delay (1000ms)
const customDebounceWatcher = new FileWatcher(
"/mock/workspace",
mockContext,
mockCacheManager,
mockEmbedder,
mockVectorStore,
mockIgnoreInstance,
undefined, // ignoreController
undefined, // batchSegmentThreshold
1000, // custom debounce delay (1000ms)
5, // custom concurrency limit
)
await customDebounceWatcher.initialize()
// Trigger a file event
await mockOnDidCreate({ fsPath: "/mock/workspace/src/custom-file.ts" })
// Wait for 700ms - should still not have processed (custom debounce is 1000ms)
await new Promise((resolve) => setTimeout(resolve, 700))
// No batch processing should have happened yet with 1000ms debounce
// Note: The file watcher uses its own internal debounce timer
// Clean up
customDebounceWatcher.dispose()
})
it("should use custom concurrency limit for file processing", async () => {
// Create a file watcher with a lower custom concurrency limit
const customConcurrencyWatcher = new FileWatcher(
"/mock/workspace",
mockContext,
mockCacheManager,
mockEmbedder,
mockVectorStore,
mockIgnoreInstance,
undefined, // ignoreController
undefined, // batchSegmentThreshold
100, // short debounce for faster test
2, // low concurrency limit (2)
)
await customConcurrencyWatcher.initialize()
// Clean up
customConcurrencyWatcher.dispose()
})
it("should accept all optional parameters including debounce and concurrency", async () => {
// Test that the constructor accepts all parameters without errors
const fullyConfiguredWatcher = new FileWatcher(
"/mock/workspace",
mockContext,
mockCacheManager,
mockEmbedder,
mockVectorStore,
mockIgnoreInstance,
undefined, // ignoreController
50, // batchSegmentThreshold
2000, // debounceMs
5, // concurrencyLimit
)
expect(fullyConfiguredWatcher).toBeDefined()
// Initialize and dispose to ensure no runtime errors
await fullyConfiguredWatcher.initialize()
fullyConfiguredWatcher.dispose()
})
})
})

View file

@ -5,6 +5,8 @@ import {
BATCH_SEGMENT_THRESHOLD,
MAX_BATCH_RETRIES,
INITIAL_RETRY_DELAY_MS,
DEFAULT_FILE_WATCHER_DEBOUNCE_MS,
DEFAULT_FILE_WATCHER_CONCURRENCY,
} from "../constants"
import { createHash } from "crypto"
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
@ -37,8 +39,8 @@ export class FileWatcher implements IFileWatcher {
private ignoreController: RooIgnoreController
private accumulatedEvents: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }> = new Map()
private batchProcessDebounceTimer?: NodeJS.Timeout
private readonly BATCH_DEBOUNCE_DELAY_MS = 500
private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10
private readonly batchDebounceDelayMs: number
private readonly fileProcessingConcurrencyLimit: number
private readonly batchSegmentThreshold: number
private readonly _onDidStartBatchProcessing = new vscode.EventEmitter<string[]>()
@ -68,9 +70,14 @@ export class FileWatcher implements IFileWatcher {
* Creates a new file watcher
* @param workspacePath Path to the workspace
* @param context VS Code extension context
* @param cacheManager Cache manager
* @param embedder Optional embedder
* @param vectorStore Optional vector store
* @param cacheManager Cache manager
* @param ignoreInstance Optional ignore instance for .gitignore filtering
* @param ignoreController Optional RooIgnoreController for .rooignore filtering
* @param batchSegmentThreshold Optional batch segment threshold for embeddings
* @param debounceMs Optional debounce delay in ms (default 500ms). Higher values reduce CPU usage in multi-worktree scenarios.
* @param concurrencyLimit Optional concurrency limit for file processing (default 10). Lower values reduce CPU usage in multi-worktree scenarios.
*/
constructor(
private workspacePath: string,
@ -81,6 +88,8 @@ export class FileWatcher implements IFileWatcher {
ignoreInstance?: Ignore,
ignoreController?: RooIgnoreController,
batchSegmentThreshold?: number,
debounceMs?: number,
concurrencyLimit?: number,
) {
this.ignoreController = ignoreController || new RooIgnoreController(workspacePath)
if (ignoreInstance) {
@ -100,6 +109,14 @@ export class FileWatcher implements IFileWatcher {
this.batchSegmentThreshold = BATCH_SEGMENT_THRESHOLD
}
}
// Set configurable debounce delay for multi-worktree optimization
// Higher values batch more file changes together, reducing CPU spikes
this.batchDebounceDelayMs = debounceMs ?? DEFAULT_FILE_WATCHER_DEBOUNCE_MS
// Set configurable concurrency limit for multi-worktree optimization
// Lower values process fewer files in parallel, reducing peak CPU usage
this.fileProcessingConcurrencyLimit = concurrencyLimit ?? DEFAULT_FILE_WATCHER_CONCURRENCY
}
/**
@ -162,12 +179,13 @@ export class FileWatcher implements IFileWatcher {
/**
* Schedules batch processing with debounce
* Uses configurable debounce delay for multi-worktree optimization
*/
private scheduleBatchProcessing(): void {
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.BATCH_DEBOUNCE_DELAY_MS)
this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.batchDebounceDelayMs)
}
/**
@ -265,8 +283,9 @@ export class FileWatcher implements IFileWatcher {
const successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }> = []
const filesToProcessConcurrently = [...filesToUpsertDetails]
for (let i = 0; i < filesToProcessConcurrently.length; i += this.FILE_PROCESSING_CONCURRENCY_LIMIT) {
const chunkToProcess = filesToProcessConcurrently.slice(i, i + this.FILE_PROCESSING_CONCURRENCY_LIMIT)
// Use configurable concurrency limit for multi-worktree optimization
for (let i = 0; i < filesToProcessConcurrently.length; i += this.fileProcessingConcurrencyLimit) {
const chunkToProcess = filesToProcessConcurrently.slice(i, i + this.fileProcessingConcurrencyLimit)
const chunkProcessingPromises = chunkToProcess.map(async (fileDetail) => {
this._onBatchProgressUpdate.fire({

View file

@ -191,6 +191,7 @@ export class CodeIndexServiceFactory {
/**
* Creates a file watcher instance with its required dependencies.
* Passes configurable performance settings for multi-worktree optimization.
*/
public createFileWatcher(
context: vscode.ExtensionContext,
@ -200,6 +201,8 @@ export class CodeIndexServiceFactory {
ignoreInstance: Ignore,
rooIgnoreController?: RooIgnoreController,
): IFileWatcher {
const config = this.configManager.getConfig()
// Get the configurable batch size from VSCode settings
let batchSize: number
try {
@ -210,6 +213,12 @@ export class CodeIndexServiceFactory {
// In test environment, vscode.workspace might not be available
batchSize = BATCH_SEGMENT_THRESHOLD
}
// Get configurable file watcher performance settings from config manager
// These allow users to optimize CPU usage for multi-worktree scenarios
const debounceMs = config.fileWatcherDebounceMs
const concurrencyLimit = config.fileWatcherConcurrency
return new FileWatcher(
this.workspacePath,
context,
@ -219,6 +228,8 @@ export class CodeIndexServiceFactory {
ignoreInstance,
rooIgnoreController,
batchSize,
debounceMs,
concurrencyLimit,
)
}