Skip to content
Draft
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
18 changes: 18 additions & 0 deletions packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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>
Expand Down
114 changes: 114 additions & 0 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
36 changes: 35 additions & 1 deletion src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -460,6 +472,8 @@ export class CodeIndexConfigManager {
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
searchMaxResults: this.currentSearchMaxResults,
fileWatcherDebounceMs: this.currentFileWatcherDebounceMs,
fileWatcherConcurrency: this.currentFileWatcherConcurrency,
}
}

Expand Down Expand Up @@ -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
}
}
8 changes: 8 additions & 0 deletions src/services/code-index/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/services/code-index/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
Loading
Loading