diff --git a/.gitattributes b/.gitattributes index e9e36432cd..550c08fe69 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,9 @@ demo.gif filter=lfs diff=lfs merge=lfs -text assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text src/assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text +# pnpm hashes patch bytes; keep LF on every checkout. +patches/*.patch text eol=lf + # Test snapshot files - mark as linguist-generated to exclude from GitHub language statistics *.snap linguist-generated=true diff --git a/knip.json b/knip.json index 1f4956faa6..8535bcc70c 100644 --- a/knip.json +++ b/knip.json @@ -22,7 +22,6 @@ "!**/__tests__/**!", "!vitest.setup.ts!", "!utils/vitest-verbosity.ts!", - "!utils/TaskSemaphore.ts!", "!utils/WorkspacePathResolver.ts!", "!test-utils/**!", "!utils/versionToken.ts!", diff --git a/package.json b/package.json index 04922487f6..840700f103 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,9 @@ "zod": "4.6.7", "@lmstudio/sdk>zod": "3.25.76", "katex": "0.19.1" + }, + "patchedDependencies": { + "@qdrant/js-client-rest@1.20.0": "patches/@qdrant__js-client-rest@1.20.0.patch" } } } diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8a006c0639..50ea4a82df 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -857,6 +857,9 @@ export type CheckpointRestorePayload = z.infer } +export interface FailedFileStatus { + path: string + activity: "read" | "parse" | "embed" | "upsert" | "delete" + reason: string + state: "retrying" | "exhausted" +} + +export interface FailedFileSummary extends FailedFileStatus { + count: number +} + +export interface ProviderWaitStatus { + service: string + resumeAt?: number + waitingFiles: string[] +} + export interface IndexingStatus { systemStatus: string message?: string @@ -882,6 +902,9 @@ export interface IndexingStatus { workspacePath?: string workspaceEnabled?: boolean autoEnableDefault?: boolean + failedFiles?: FailedFileStatus[] + failedFileSummary?: FailedFileSummary + providerWaits?: ProviderWaitStatus[] } export interface IndexingStatusUpdateMessage { diff --git a/patches/@qdrant__js-client-rest@1.20.0.patch b/patches/@qdrant__js-client-rest@1.20.0.patch new file mode 100644 index 0000000000..eea88bb792 --- /dev/null +++ b/patches/@qdrant__js-client-rest@1.20.0.patch @@ -0,0 +1,56 @@ +diff --git a/dist/browser/index.cjs b/dist/browser/index.cjs +--- a/dist/browser/index.cjs ++++ b/dist/browser/index.cjs +@@ -991,8 +991,8 @@ + } + catch (error) { + if (error instanceof ApiError && error.status === 429) { +- const retryAfterHeader = error.headers.get('retry-after')?.[0]; +- if (retryAfterHeader) { ++ const retryAfterHeader = error.headers.get('retry-after'); ++ if (retryAfterHeader && !isNaN(Number(retryAfterHeader))) { + throw new QdrantClientResourceExhaustedError(error.message, retryAfterHeader); + } + } +diff --git a/dist/browser/index.js b/dist/browser/index.js +--- a/dist/browser/index.js ++++ b/dist/browser/index.js +@@ -989,8 +989,8 @@ + } + catch (error) { + if (error instanceof ApiError && error.status === 429) { +- const retryAfterHeader = error.headers.get('retry-after')?.[0]; +- if (retryAfterHeader) { ++ const retryAfterHeader = error.headers.get('retry-after'); ++ if (retryAfterHeader && !isNaN(Number(retryAfterHeader))) { + throw new QdrantClientResourceExhaustedError(error.message, retryAfterHeader); + } + } +diff --git a/dist/cjs/api-client.js b/dist/cjs/api-client.js +--- a/dist/cjs/api-client.js ++++ b/dist/cjs/api-client.js +@@ -51,8 +51,8 @@ + } + catch (error) { + if (error instanceof openapi_typescript_fetch_1.ApiError && error.status === 429) { +- const retryAfterHeader = error.headers.get('retry-after')?.[0]; +- if (retryAfterHeader) { ++ const retryAfterHeader = error.headers.get('retry-after'); ++ if (retryAfterHeader && !isNaN(Number(retryAfterHeader))) { + throw new errors_js_1.QdrantClientResourceExhaustedError(error.message, retryAfterHeader); + } + } +diff --git a/dist/esm/api-client.js b/dist/esm/api-client.js +--- a/dist/esm/api-client.js ++++ b/dist/esm/api-client.js +@@ -47,8 +47,8 @@ + } + catch (error) { + if (error instanceof ApiError && error.status === 429) { +- const retryAfterHeader = error.headers.get('retry-after')?.[0]; +- if (retryAfterHeader) { ++ const retryAfterHeader = error.headers.get('retry-after'); ++ if (retryAfterHeader && !isNaN(Number(retryAfterHeader))) { + throw new QdrantClientResourceExhaustedError(error.message, retryAfterHeader); + } + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a949abf377..6f1a2491f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,11 @@ overrides: '@lmstudio/sdk>zod': 3.25.76 katex: 0.19.1 +patchedDependencies: + '@qdrant/js-client-rest@1.20.0': + hash: 25b0bcb3835c3b5fe58aa29056bebb895a7d48958dac9d69a33ca7c7186fadd9 + path: patches/@qdrant__js-client-rest@1.20.0.patch + importers: .: @@ -470,8 +475,8 @@ importers: specifier: 1.32.2 version: 1.32.2(zod@4.6.7) '@qdrant/js-client-rest': - specifier: ^1.14.0 - version: 1.20.0(typescript@5.9.3) + specifier: 1.20.0 + version: 1.20.0(patch_hash=25b0bcb3835c3b5fe58aa29056bebb895a7d48958dac9d69a33ca7c7186fadd9)(typescript@5.9.3) '@roo-code/cloud': specifier: workspace:^ version: link:../packages/cloud @@ -10697,7 +10702,7 @@ snapshots: '@protobufjs/utf8@1.1.2': {} - '@qdrant/js-client-rest@1.20.0(typescript@5.9.3)': + '@qdrant/js-client-rest@1.20.0(patch_hash=25b0bcb3835c3b5fe58aa29056bebb895a7d48958dac9d69a33ca7c7186fadd9)(typescript@5.9.3)': dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.9.3 diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index abcd3c28a0..28dba2649e 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -388,6 +388,9 @@ describe("extension.ts", () => { workspacePath: "/workspace", workspaceEnabled: true, autoEnableDefault: true, + failedFiles: [], + failedFileSummary: undefined, + providerWaits: [], } scope["statusManager"]!["publishStatus"](status) await Promise.resolve() @@ -434,6 +437,9 @@ describe("extension.ts", () => { workspacePath: "/workspace", workspaceEnabled: true, autoEnableDefault: true, + failedFiles: [], + failedFileSummary: undefined, + providerWaits: [], } scope["statusManager"]!["publishStatus"](status) await Promise.resolve() diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index 2d1228794b..ee5f52efa8 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -4,7 +4,6 @@ import { Task } from "../task/Task" import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" -import { VectorStoreSearchResult } from "../../services/code-index/interfaces" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -78,18 +77,36 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { throw new Error("Code Indexing is not initialized for this workspace.") } - const searchResults: VectorStoreSearchResult[] = await manager.searchIndex(query, directoryPrefix) - - if (!searchResults || searchResults.length === 0) { - pushToolResult(`No relevant code snippets found for the query: "${query}"`) - return - } + const { + results: searchResults, + incomplete, + failedPaths, + waitingPaths, + } = await manager.searchIndex(query, directoryPrefix) + const incompleteNotice = incomplete + ? [ + "Partial index: results may be incomplete.", + ...(failedPaths.length ? ["Failed files:", ...failedPaths.map((file) => `- ${file}`)] : []), + ...(waitingPaths.length + ? [ + "Waiting files (indexing resumes automatically):", + ...waitingPaths.map((file) => `- ${file}`), + ] + : []), + ].join("\n") + "\n\n" + : "" const jsonResult = { query, results: [], + incomplete, + failedPaths, + waitingPaths, } as { query: string + incomplete: boolean + failedPaths: string[] + waitingPaths: string[] results: Array<{ filePath: string score: number @@ -117,7 +134,12 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { const payload = { tool: "codebaseSearch", content: jsonResult } await task.say("codebase_search_result", JSON.stringify(payload)) - const output = `Query: ${query} + if (jsonResult.results.length === 0) { + pushToolResult(`${incompleteNotice}No relevant code snippets found for the query: "${query}"`) + return + } + + const output = `${incompleteNotice}Query: ${query} Results: ${jsonResult.results @@ -131,7 +153,7 @@ Code Chunk: ${result.codeChunk} .join("\n")}` pushToolResult(output) - } catch (error: any) { + } catch (error) { await handleError("codebase_search", error) } } diff --git a/src/core/tools/__tests__/CodebaseSearchTool.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts index eb0c27c1ae..b8889b5d0b 100644 --- a/src/core/tools/__tests__/CodebaseSearchTool.spec.ts +++ b/src/core/tools/__tests__/CodebaseSearchTool.spec.ts @@ -75,7 +75,7 @@ describe("CodebaseSearchTool", () => { isFeatureConfigured: true, isInitialized: true, initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), - searchIndex: vi.fn().mockResolvedValue([]), + searchIndex: vi.fn().mockResolvedValue(searchResponse()), } vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue(manager as CodeIndexManager) vi.mocked(getWorkspacePath).mockReturnValue("/fallback") @@ -96,6 +96,15 @@ describe("CodebaseSearchTool", () => { } } + function searchResponse( + results: VectorStoreSearchResult[] = [], + failedPaths: string[] = [], + waitingPaths: string[] = [], + incomplete = false, + ): Awaited> { + return { results, incomplete, failedPaths, waitingPaths } + } + it("exports a named tool instance", () => { expect(codebaseSearchTool).toBeInstanceOf(CodebaseSearchTool) expect(codebaseSearchTool.name).toBe(toolNamesSchema.enum.codebase_search) @@ -264,7 +273,7 @@ describe("CodebaseSearchTool", () => { async (path) => { vi.mocked(manager.searchIndex).mockImplementation(async () => { expect(task.consecutiveMistakeCount).toBe(0) - return [] + return searchResponse() }) await tool.execute({ query, path }, task, callbacks) expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenCalledExactlyOnceWith(context, "/task") @@ -273,36 +282,73 @@ describe("CodebaseSearchTool", () => { `No relevant code snippets found for the query: "${query}"`, ) expect(callbacks.handleError).not.toHaveBeenCalled() - expect(task.say).not.toHaveBeenCalled() + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "codebase_search_result", + JSON.stringify({ + tool: "codebaseSearch", + content: { query, results: [], incomplete: false, failedPaths: [], waitingPaths: [] }, + }), + ) + expect(task.say).toHaveBeenCalledBefore(vi.mocked(callbacks.pushToolResult)) expect(vscode.workspace.asRelativePath).not.toHaveBeenCalled() expect(getWorkspacePath).not.toHaveBeenCalled() }, ) - it.each([null, undefined, false, 0, ""])( - "defensively handles a runtime-invalid falsy search response %j", - async (value) => { - // The manager promises an array. Deliberately violate that boundary to exercise the existing falsy guard. - vi.mocked(manager.searchIndex).mockResolvedValue(value as unknown as VectorStoreSearchResult[]) - await tool.execute({ query }, task, callbacks) - expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( - `No relevant code snippets found for the query: "${query}"`, - ) - expect(callbacks.handleError).not.toHaveBeenCalled() - expect(task.say).not.toHaveBeenCalled() - expect(vscode.workspace.asRelativePath).not.toHaveBeenCalled() - }, - ) + it.each([false, true])("CI-TL-01: warns about an incomplete index with matches: %s", async (hasMatches) => { + const failedPaths = ["src/failed.ts", "other/unreadable.ts"] + vi.mocked(manager.searchIndex).mockResolvedValue( + searchResponse(hasMatches ? [result()] : [], failedPaths, [], true), + ) - it("preserves result order and metadata, relativizes paths without workspace prefixes and trims chunks", async () => { - vi.mocked(manager.searchIndex).mockResolvedValue([ - result(), - result({ - id: "second", - score: 0.5, - payload: { filePath: "/task/lib/other.ts", startLine: 10, endLine: 10, codeChunk: " \t " }, + await tool.execute({ query, path: "src" }, task, callbacks) + + expect(manager.searchIndex).toHaveBeenCalledExactlyOnceWith(query, "src") + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "codebase_search_result", + JSON.stringify({ + tool: "codebaseSearch", + content: { + query, + results: hasMatches + ? [ + { + filePath: "src/result.ts", + score: 0.9, + startLine: 2, + endLine: 4, + codeChunk: "first\n second", + }, + ] + : [], + incomplete: true, + failedPaths, + waitingPaths: [], + }, }), - ]) + ) + const notice = + "Partial index: results may be incomplete.\nFailed files:\n- src/failed.ts\n- other/unreadable.ts\n\n" + const output = hasMatches + ? `Query: ${query}\nResults:\n\nFile path: src/result.ts\nScore: 0.9\nLines: 2-4\nCode Chunk: first\n second\n` + : `No relevant code snippets found for the query: "${query}"` + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith(notice + output) + expect(task.say).toHaveBeenCalledBefore(vi.mocked(callbacks.pushToolResult)) + expect(vscode.workspace.asRelativePath).toHaveBeenCalledTimes(hasMatches ? 1 : 0) + expect(callbacks.handleError).not.toHaveBeenCalled() + }) + + it("preserves result order and metadata, relativizes paths without workspace prefixes and trims chunks", async () => { + vi.mocked(manager.searchIndex).mockResolvedValue( + searchResponse([ + result(), + result({ + id: "second", + score: 0.5, + payload: { filePath: "/task/lib/other.ts", startLine: 10, endLine: 10, codeChunk: " \t " }, + }), + ]), + ) vi.mocked(vscode.workspace.asRelativePath) .mockReturnValueOnce("src/result.ts") .mockReturnValueOnce("lib/other.ts") @@ -328,6 +374,9 @@ describe("CodebaseSearchTool", () => { }, { filePath: "lib/other.ts", score: 0.5, startLine: 10, endLine: 10, codeChunk: "" }, ], + incomplete: false, + failedPaths: [], + waitingPaths: [], }, }), ) @@ -341,12 +390,9 @@ describe("CodebaseSearchTool", () => { it("skips malformed entries while preserving a valid result", async () => { // Missing filePath is invalid under Payload's type but explicitly guarded against at runtime. const missingPath = { id: "malformed", score: 1, payload: { codeChunk: "ignored" } } as VectorStoreSearchResult - vi.mocked(manager.searchIndex).mockResolvedValue([ - result({ payload: undefined }), - result({ payload: null }), - missingPath, - result(), - ]) + vi.mocked(manager.searchIndex).mockResolvedValue( + searchResponse([result({ payload: undefined }), result({ payload: null }), missingPath, result()]), + ) await tool.execute({ query }, task, callbacks) expect(vscode.workspace.asRelativePath).toHaveBeenCalledExactlyOnceWith("/task/src/result.ts", false) expect(task.say).toHaveBeenCalledExactlyOnceWith( @@ -364,6 +410,9 @@ describe("CodebaseSearchTool", () => { codeChunk: "first\n second", }, ], + incomplete: false, + failedPaths: [], + waitingPaths: [], }, }), ) @@ -373,23 +422,26 @@ describe("CodebaseSearchTool", () => { expect(callbacks.handleError).not.toHaveBeenCalled() }) - it("preserves the existing empty-header response when every entry is skipped", async () => { + it("reports no snippets with complete metadata when every entry is skipped", async () => { // Missing filePath deliberately violates the backend payload contract. const missingPath = { id: "malformed", score: 1, payload: { codeChunk: "ignored" } } as VectorStoreSearchResult - vi.mocked(manager.searchIndex).mockResolvedValue([ - result({ payload: undefined }), - result({ payload: null }), - missingPath, - ]) + vi.mocked(manager.searchIndex).mockResolvedValue( + searchResponse([result({ payload: undefined }), result({ payload: null }), missingPath]), + ) await tool.execute({ query }, task, callbacks) expect(vscode.workspace.asRelativePath).not.toHaveBeenCalled() expect(task.say).toHaveBeenCalledExactlyOnceWith( "codebase_search_result", - JSON.stringify({ tool: "codebaseSearch", content: { query, results: [] } }), + JSON.stringify({ + tool: "codebaseSearch", + content: { query, results: [], incomplete: false, failedPaths: [], waitingPaths: [] }, + }), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + `No relevant code snippets found for the query: "${query}"`, ) - expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith(`Query: ${query}\nResults:\n\n`) expect(callbacks.handleError).not.toHaveBeenCalled() }) @@ -425,7 +477,7 @@ describe("CodebaseSearchTool", () => { it("forwards a result-message rejection without publishing a text result", async () => { const error = new Error("say failed") - vi.mocked(manager.searchIndex).mockResolvedValue([result()]) + vi.mocked(manager.searchIndex).mockResolvedValue(searchResponse([result()])) vi.mocked(task.say).mockRejectedValue(error) await tool.execute({ query }, task, callbacks) @@ -521,4 +573,38 @@ describe("CodebaseSearchTool", () => { expect(task.say).not.toHaveBeenCalled() }) }) + + it.each([false, true])( + "prints separate waiting and failed warnings before output, matches=%s", + async (hasMatches) => { + vi.mocked(manager.searchIndex).mockResolvedValue( + searchResponse(hasMatches ? [result()] : [], ["failed.ts"], ["waiting.ts"], true), + ) + await tool.execute({ query }, task, callbacks) + const output = vi.mocked(callbacks.pushToolResult).mock.calls[0][0] + expect(output).toEqual( + expect.stringContaining( + "Partial index: results may be incomplete.\nFailed files:\n- failed.ts\n" + + "Waiting files (indexing resumes automatically):\n- waiting.ts\n\n", + ), + ) + expect(output).toEqual(expect.stringContaining(hasMatches ? "Query:" : "No relevant code snippets")) + }, + ) + + it.each([ + { waitingPaths: [], expected: "Partial index: results may be incomplete.\n\n" }, + { + waitingPaths: ["waiting.ts"], + expected: + "Partial index: results may be incomplete.\n" + + "Waiting files (indexing resumes automatically):\n- waiting.ts\n\n", + }, + ])("follows the explicit incomplete flag with waiting paths $waitingPaths", async ({ waitingPaths, expected }) => { + vi.mocked(manager.searchIndex).mockResolvedValue(searchResponse([], [], waitingPaths, true)) + await tool.execute({ query }, task, callbacks) + const output = vi.mocked(callbacks.pushToolResult).mock.calls[0][0] + expect(output).toEqual(expect.stringContaining(expected)) + expect(output).not.toEqual(expect.stringContaining("Failed files:")) + }) }) diff --git a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts index f6f6c0f5cb..475739a3f1 100644 --- a/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts +++ b/src/core/tools/__tests__/CodebaseSearchTool.workspace.spec.ts @@ -37,17 +37,23 @@ vi.mock("../../../services/code-index/manager", () => ({ initialized = true return { requiresRestart: false } }), - searchIndex: vi.fn().mockResolvedValue([ - { - score: 0.9, - payload: { - filePath: `${workspacePath}/src/result.ts`, - startLine: 2, - endLine: 4, - codeChunk: " match ", + searchIndex: vi.fn().mockResolvedValue({ + results: [ + { + id: "workspace-result", + score: 0.9, + payload: { + filePath: `${workspacePath}/src/result.ts`, + startLine: 2, + endLine: 4, + codeChunk: " match ", + }, }, - }, - ]), + ], + incomplete: false, + failedPaths: [], + waitingPaths: [], + }), dispose: vi.fn(), } }), @@ -122,6 +128,9 @@ describe("CodebaseSearchTool workspace selection", () => { results: [ { filePath: "/second/src/result.ts", score: 0.9, startLine: 2, endLine: 4, codeChunk: "match" }, ], + incomplete: false, + failedPaths: [], + waitingPaths: [], }, }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 464889418e..45fd7b11d9 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6022,13 +6022,11 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { expect(provider.log).toHaveBeenCalledWith("Cannot start indexing: No workspace folder open") }) - it("catches both start-indexing calls during error recovery", async () => { + it("starts once from Error and leaves recovery to the manager", async () => { const manager = createIndexManager({ + state: "Error", isInitialized: false, - startIndexing: vi - .fn() - .mockRejectedValueOnce(new Error("first failure")) - .mockRejectedValueOnce(new Error("second failure")), + startIndexing: vi.fn().mockRejectedValueOnce(new Error("recovery failure")), }) const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), @@ -6037,9 +6035,9 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { await webviewMessageHandler(provider, { type: "startIndexing" }) await Promise.resolve() - expect(manager.startIndexing).toHaveBeenCalledTimes(2) - expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: first failure") - expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: second failure") + expect(manager.initialize).toHaveBeenCalledOnce() + expect(manager.startIndexing).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: recovery failure") }) it("covers changed stop, toggle, and detached toggle rejection paths", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 98c306a4f3..18836fa440 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -773,6 +773,81 @@ describe("webviewMessageHandler - theme fixture probes", () => { }) }) +describe("CI-WV-01: code index start and status forwarding", () => { + const status = { + systemStatus: "Standby", + message: "partial", + processedItems: 1, + totalItems: 2, + currentItemUnit: "blocks", + failedFiles: [{ path: "a.ts", activity: "embed", reason: "down", state: "exhausted" }], + providerWaits: [{ service: "openai", resumeAt: 1, waitingFiles: ["b.ts"] }], + } + + function withManager(state: string) { + const manager = { + setWorkspaceEnabled: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + startIndexing: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn().mockResolvedValue(undefined), + getCurrentStatus: vi.fn().mockReturnValue(status), + isFeatureEnabled: true, + isFeatureConfigured: true, + isInitialized: true, + state, + } + // The double provides only the scope lookup and webview members these handlers call. + const provider = { + ...mockClineProvider, + postMessageToWebview: vi.fn(), + getCurrentWorkspaceCodeIndexScope: () => ({ codeIndexManager: manager }), + } as unknown as ClineProvider + return { manager, provider } + } + + beforeEach(() => vi.clearAllMocks()) + + it.each(["Standby", "Error"])("starts indexing once from %s without awaiting the start", async (state) => { + const { manager, provider } = withManager(state) + manager.startIndexing.mockReturnValue(new Promise(() => {})) + await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() + expect(manager.startIndexing).toHaveBeenCalledTimes(1) + }) + + it("logs a start failure instead of rejecting", async () => { + const { manager, provider } = withManager("Standby") + manager.startIndexing.mockRejectedValue(new Error("boom")) + await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: boom") + }) + + it("does not start again while indexing is active", async () => { + const { manager, provider } = withManager("Indexing") + await webviewMessageHandler(provider, { type: "startIndexing" }) + expect(manager.startIndexing).not.toHaveBeenCalled() + }) + + it("forwards failed files and provider waits only after the stop finishes", async () => { + const { manager, provider } = withManager("Indexing") + let finishStop!: () => void + manager.stopIndexing.mockReturnValue(new Promise((resolve) => (finishStop = resolve))) + manager.getCurrentStatus.mockReturnValue({ ...status, systemStatus: "Stopping" }) + const handling = webviewMessageHandler(provider, { type: "stopIndexing" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + + manager.getCurrentStatus.mockReturnValue(status) + finishStop() + await handling + expect(manager.stopIndexing).toHaveBeenCalledTimes(1) + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "indexingStatusUpdate", + values: status, + }) + }) +}) + import { changeLanguage, t } from "../../../i18n" vi.mock("vscode", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c5abbc0a12..65916383be 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3530,14 +3530,9 @@ export const webviewMessageHandler = async ( const currentState = manager.state if (currentState === "Standby" || currentState === "Error") { + // Detached: a full scan must not block the message handler. From Error, the manager + // itself recovers, re-initializes and starts. void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - - if (!manager.isInitialized) { - await manager.initialize(provider.contextProxy) - if (manager.state === "Standby" || manager.state === "Error") { - void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - } - } } } } catch (error) { @@ -3552,7 +3547,7 @@ export const webviewMessageHandler = async ( provider.log("Cannot stop indexing: No workspace folder open") return } - scope.codeIndexManager.stopIndexing() + await scope.codeIndexManager.stopIndexing() await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: scope.codeIndexManager.getCurrentStatus(), @@ -3594,7 +3589,7 @@ export const webviewMessageHandler = async ( const wasEnabled = priorStates.get(m)! const isNowEnabled = m.isWorkspaceEnabled if (wasEnabled && !isNowEnabled) { - m.stopIndexing() + await m.stopIndexing() } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { await m.initialize(provider.contextProxy) void m.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index ae3d504003..94035beff0 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -769,11 +769,6 @@ "count": 1 } }, - "core/tools/CodebaseSearchTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "core/tools/GenerateImageTool.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1029,21 +1024,11 @@ "count": 84 } }, - "services/code-index/__tests__/orchestrator.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 23 - } - }, "services/code-index/__tests__/service-factory.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 43 } }, - "services/code-index/embedders/__tests__/bedrock.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, "services/code-index/embedders/__tests__/gemini.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 @@ -1059,26 +1044,6 @@ "count": 1 } }, - "services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 15 - } - }, - "services/code-index/embedders/__tests__/openai-compatible.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 28 - } - }, - "services/code-index/embedders/__tests__/openai.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, - "services/code-index/embedders/__tests__/openrouter.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1086,7 +1051,7 @@ }, "services/code-index/embedders/bedrock.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "services/code-index/embedders/ollama.ts": { @@ -1099,14 +1064,9 @@ "count": 1 } }, - "services/code-index/embedders/openai.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "services/code-index/embedders/openrouter.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 } }, "services/code-index/interfaces/vector-store.ts": { @@ -1126,7 +1086,7 @@ }, "services/code-index/processors/__tests__/parser.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 22 + "count": 19 } }, "services/code-index/processors/__tests__/scanner.spec.ts": { @@ -1134,16 +1094,6 @@ "count": 26 } }, - "services/code-index/processors/file-watcher.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "services/code-index/processors/scanner.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "services/code-index/semble/__tests__/provider.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 12 @@ -1181,12 +1131,12 @@ }, "services/code-index/vector-store/__tests__/qdrant-client.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 87 + "count": 85 } }, "services/code-index/vector-store/qdrant-client.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 3 } }, "services/glob/__tests__/gitignore-integration.spec.ts": { diff --git a/src/i18n/locales/ca/embeddings.json b/src/i18n/locales/ca/embeddings.json index 6955d53b46..225925dc55 100644 --- a/src/i18n/locales/ca/embeddings.json +++ b/src/i18n/locales/ca/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "El proveïdor de cerca ha limitat les sol·licituds. La indexació es reprèn automàticament quan acaba l’espera del proveïdor.", "authenticationFailed": "No s'han pogut crear les incrustacions: ha fallat l'autenticació. Comproveu la vostra clau d'API.", "failedWithStatus": "No s'han pogut crear les incrustacions després de {{attempts}} intents: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "No s'han pogut crear les incrustacions després de {{attempts}} intents: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "No es poden crear serveis: La indexació de codi no està configurada correctament" }, "orchestrator": { + "providerWaiting": "Esperant {{service}}; la indexació es reprèn automàticament", + "providerWaitingUntil": "Esperant {{service}}; la indexació es reprèn automàticament a les {{time}}", + "indexingFilesFailed": "Encara hi ha errors en fitxers. No hi ha reintents automàtics pendents. Hi ha un índex parcial disponible.", + "indexingStopping": "S'està aturant la indexació...", "indexingFailedNoBlocks": "Indexació fallida: No s'ha indexat cap bloc de codi amb èxit. Això normalment indica un problema de configuració de l'embedder.", "fileWatcherStarted": "Monitor de fitxers iniciat.", "fileWatcherStopped": "Monitor de fitxers aturat.", @@ -64,7 +69,8 @@ "unknownError": "Error desconegut", "indexingRequiresWorkspace": "Indexació requereix una carpeta de workspace oberta", "indexingStopped": "Indexació aturada per l'usuari.", - "indexingStoppedPartial": "Indexació aturada. Dades d'índex parcials conservades." + "indexingStoppedPartial": "Indexació aturada. Dades d'índex parcials conservades.", + "cacheReadFailed": "No s'ha pogut llegir la memòria cau de l'índex de codi. S'han conservat les dades de l'índex existent. Torna a iniciar la indexació per tornar-ho a provar." }, "semble": { "downloadingBinary": "Descarregant el binari de semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Error en descarregar semble: {{errorMessage}}", "checkFailed": "Comprovació de semble fallida: {{errorMessage}}", "providerReset": "Proveïdor de Semble restablert. La memòria cau al disc es conserva fins a la propera reconstrucció." - } + }, + "quotaExceeded": "S'ha arribat a la quota o al límit de facturació del proveïdor d'incrustacions. Revisa el teu pla o la facturació i torna a iniciar la indexació." } diff --git a/src/i18n/locales/de/embeddings.json b/src/i18n/locales/de/embeddings.json index 8572e02863..3b94542729 100644 --- a/src/i18n/locales/de/embeddings.json +++ b/src/i18n/locales/de/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Der Suchanbieter hat die Anfragerate begrenzt. Die Indexierung wird nach der Wartezeit des Anbieters automatisch fortgesetzt.", "authenticationFailed": "Erstellung von Einbettungen fehlgeschlagen: Authentifizierung fehlgeschlagen. Bitte überprüfe deinen API-Schlüssel.", "failedWithStatus": "Erstellung von Einbettungen nach {{attempts}} Versuchen fehlgeschlagen: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Erstellung von Einbettungen nach {{attempts}} Versuchen fehlgeschlagen: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Kann keine Dienste erstellen: Code-Indizierung ist nicht richtig konfiguriert" }, "orchestrator": { + "providerWaiting": "Warten auf {{service}}; die Indexierung wird automatisch fortgesetzt", + "providerWaitingUntil": "Warten auf {{service}}; die Indexierung wird um {{time}} automatisch fortgesetzt", + "indexingFilesFailed": "Es bestehen weiterhin Dateifehler. Keine automatischen Wiederholungen ausstehend. Ein Teilindex ist verfügbar.", + "indexingStopping": "Indexierung wird gestoppt...", "indexingFailedNoBlocks": "Indizierung fehlgeschlagen: Keine Code-Blöcke wurden erfolgreich indiziert. Dies deutet normalerweise auf ein Embedder-Konfigurationsproblem hin.", "fileWatcherStarted": "Datei-Watcher gestartet.", "fileWatcherStopped": "Datei-Watcher gestoppt.", @@ -64,7 +69,8 @@ "unknownError": "Unbekannter Fehler", "indexingRequiresWorkspace": "Indexierung erfordert einen offenen Workspace-Ordner", "indexingStopped": "Indexierung vom Benutzer gestoppt.", - "indexingStoppedPartial": "Indexierung gestoppt. Teilweise Indexdaten beibehalten." + "indexingStoppedPartial": "Indexierung gestoppt. Teilweise Indexdaten beibehalten.", + "cacheReadFailed": "Der Cache des Code-Index konnte nicht gelesen werden. Vorhandene Indexdaten wurden beibehalten. Starte die Indexierung erneut, um es noch einmal zu versuchen." }, "semble": { "downloadingBinary": "Semble-Binary wird heruntergeladen...", @@ -73,5 +79,6 @@ "downloadFailed": "Fehler beim Herunterladen von Semble: {{errorMessage}}", "checkFailed": "Semble-Überprüfung fehlgeschlagen: {{errorMessage}}", "providerReset": "Semble-Anbieter zurückgesetzt. Der Cache auf der Festplatte bleibt bis zum nächsten Neuaufbau erhalten." - } + }, + "quotaExceeded": "Kontingent oder Abrechnungslimit des Einbettungsanbieters erreicht. Überprüfe deinen Tarif oder deine Abrechnung und starte die Indexierung erneut." } diff --git a/src/i18n/locales/en/embeddings.json b/src/i18n/locales/en/embeddings.json index accd71fc7d..ea8e29f345 100644 --- a/src/i18n/locales/en/embeddings.json +++ b/src/i18n/locales/en/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Search provider rate-limited. Indexing resumes automatically after the provider wait.", "authenticationFailed": "Failed to create embeddings: Authentication failed. Please check your API key.", "failedWithStatus": "Failed to create embeddings after {{attempts}} attempts: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Failed to create embeddings after {{attempts}} attempts: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Cannot create services: Code indexing is not properly configured" }, "orchestrator": { + "providerWaiting": "Waiting for {{service}}; indexing resumes automatically", + "providerWaitingUntil": "Waiting for {{service}}; indexing resumes automatically at {{time}}", + "indexingFilesFailed": "File failures remain. No automatic retries pending. Partial index available.", + "indexingStopping": "Stopping indexing...", "indexingFailedNoBlocks": "Indexing failed: No code blocks were successfully indexed. This usually indicates an embedder configuration issue.", "fileWatcherStarted": "File watcher started.", "fileWatcherStopped": "File watcher stopped.", @@ -64,7 +69,8 @@ "unknownError": "Unknown error", "indexingRequiresWorkspace": "Indexing requires an open workspace folder", "indexingStopped": "Indexing stopped by user.", - "indexingStoppedPartial": "Indexing stopped. Partial index data preserved." + "indexingStoppedPartial": "Indexing stopped. Partial index data preserved.", + "cacheReadFailed": "Could not read the code index cache. Existing index data was kept. Start indexing again to retry." }, "semble": { "downloadingBinary": "Downloading semble binary...", @@ -73,5 +79,6 @@ "downloadFailed": "Failed to download semble: {{errorMessage}}", "checkFailed": "Semble check failed: {{errorMessage}}", "providerReset": "Semble provider reset. On-disk cache remains until next rebuild." - } + }, + "quotaExceeded": "Embedding provider quota or billing limit reached. Check your plan or billing, then start indexing again." } diff --git a/src/i18n/locales/es/embeddings.json b/src/i18n/locales/es/embeddings.json index ea77d2d410..62cec8343e 100644 --- a/src/i18n/locales/es/embeddings.json +++ b/src/i18n/locales/es/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "El proveedor de búsqueda ha limitado las solicitudes. La indexación se reanuda automáticamente cuando termina la espera del proveedor.", "authenticationFailed": "No se pudieron crear las incrustaciones: Error de autenticación. Comprueba tu clave de API.", "failedWithStatus": "No se pudieron crear las incrustaciones después de {{attempts}} intentos: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "No se pudieron crear las incrustaciones después de {{attempts}} intentos: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "No se pueden crear servicios: La indexación de código no está configurada correctamente" }, "orchestrator": { + "providerWaiting": "Esperando a {{service}}; la indexación se reanuda automáticamente", + "providerWaitingUntil": "Esperando a {{service}}; la indexación se reanuda automáticamente a las {{time}}", + "indexingFilesFailed": "Siguen existiendo errores en archivos. No hay reintentos automáticos pendientes. Hay un índice parcial disponible.", + "indexingStopping": "Deteniendo la indexación...", "indexingFailedNoBlocks": "Indexación fallida: No se indexaron exitosamente bloques de código. Esto usualmente indica un problema de configuración del incrustador.", "fileWatcherStarted": "Monitor de archivos iniciado.", "fileWatcherStopped": "Monitor de archivos detenido.", @@ -64,7 +69,8 @@ "unknownError": "Error desconocido", "indexingRequiresWorkspace": "La indexación requiere una carpeta de workspace abierta", "indexingStopped": "Indexación detenida por el usuario.", - "indexingStoppedPartial": "Indexación detenida. Datos de índice parciales conservados." + "indexingStoppedPartial": "Indexación detenida. Datos de índice parciales conservados.", + "cacheReadFailed": "No se pudo leer la caché del índice de código. Se conservaron los datos del índice existente. Vuelve a iniciar la indexación para reintentarlo." }, "semble": { "downloadingBinary": "Descargando el binario de semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Error al descargar semble: {{errorMessage}}", "checkFailed": "Verificación de semble fallida: {{errorMessage}}", "providerReset": "Proveedor de Semble restablecido. La caché en disco se conserva hasta la próxima reconstrucción." - } + }, + "quotaExceeded": "Se ha alcanzado la cuota o el límite de facturación del proveedor de incrustaciones. Comprueba tu plan o la facturación y vuelve a iniciar la indexación." } diff --git a/src/i18n/locales/fr/embeddings.json b/src/i18n/locales/fr/embeddings.json index 187c7bac93..9cb920d34a 100644 --- a/src/i18n/locales/fr/embeddings.json +++ b/src/i18n/locales/fr/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Le fournisseur de recherche a limité la fréquence des requêtes. L’indexation reprend automatiquement après le délai d’attente du fournisseur.", "authenticationFailed": "Échec de la création des embeddings : Échec de l'authentification. Veuillez vérifier votre clé API.", "failedWithStatus": "Échec de la création des embeddings après {{attempts}} tentatives : HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Échec de la création des embeddings après {{attempts}} tentatives : {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Impossible de créer les services : L'indexation du code n'est pas correctement configurée" }, "orchestrator": { + "providerWaiting": "En attente de {{service}} ; l’indexation reprend automatiquement", + "providerWaitingUntil": "En attente de {{service}} ; l’indexation reprend automatiquement à {{time}}", + "indexingFilesFailed": "Des fichiers restent en échec. Aucune nouvelle tentative automatique en attente. Un index partiel est disponible.", + "indexingStopping": "Arrêt de l'indexation en cours...", "indexingFailedNoBlocks": "Échec de l'indexation : Aucun bloc de code n'a été indexé avec succès. Cela indique généralement un problème de configuration de l'embedder.", "fileWatcherStarted": "Surveillant de fichiers démarré.", "fileWatcherStopped": "Surveillant de fichiers arrêté.", @@ -64,7 +69,8 @@ "unknownError": "Erreur inconnue", "indexingRequiresWorkspace": "L'indexation nécessite l'ouverture d'un dossier workspace", "indexingStopped": "Indexation arrêtée par l'utilisateur.", - "indexingStoppedPartial": "Indexation arrêtée. Données d'index partielles conservées." + "indexingStoppedPartial": "Indexation arrêtée. Données d'index partielles conservées.", + "cacheReadFailed": "Impossible de lire le cache de l'index du code. Les données de l'index existant ont été conservées. Relance l'indexation pour réessayer." }, "semble": { "downloadingBinary": "Téléchargement du binaire semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Échec du téléchargement de semble : {{errorMessage}}", "checkFailed": "Vérification de semble échouée : {{errorMessage}}", "providerReset": "Fournisseur Semble réinitialisé. Le cache sur disque reste jusqu'à la prochaine reconstruction." - } + }, + "quotaExceeded": "Le quota ou la limite de facturation du fournisseur d'embeddings a été atteint. Vérifie ton forfait ou ta facturation, puis relance l'indexation." } diff --git a/src/i18n/locales/hi/embeddings.json b/src/i18n/locales/hi/embeddings.json index aea009242e..9b54b240bb 100644 --- a/src/i18n/locales/hi/embeddings.json +++ b/src/i18n/locales/hi/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "खोज प्रदाता ने अनुरोधों की दर सीमित कर दी है। प्रदाता की प्रतीक्षा अवधि पूरी होने के बाद इंडेक्सिंग अपने आप फिर शुरू होगी।", "authenticationFailed": "एम्बेडिंग बनाने में विफल: प्रमाणीकरण विफल। कृपया अपनी एपीआई कुंजी जांचें।", "failedWithStatus": "{{attempts}} प्रयासों के बाद एम्बेडिंग बनाने में विफल: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "{{attempts}} प्रयासों के बाद एम्बेडिंग बनाने में विफल: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "सेवाएं नहीं बना सकते: कोड इंडेक्सिंग ठीक से कॉन्फ़िगर नहीं है" }, "orchestrator": { + "providerWaiting": "{{service}} की प्रतीक्षा है; इंडेक्सिंग अपने आप फिर शुरू होगी", + "providerWaitingUntil": "{{service}} की प्रतीक्षा है; इंडेक्सिंग {{time}} पर अपने आप फिर शुरू होगी", + "indexingFilesFailed": "फ़ाइलों में विफलताएँ अभी भी हैं। कोई स्वचालित पुनः प्रयास लंबित नहीं है। आंशिक इंडेक्स उपलब्ध है।", + "indexingStopping": "इंडेक्सिंग रोकी जा रही है...", "indexingFailedNoBlocks": "इंडेक्सिंग असफल: कोई भी कोड ब्लॉक सफलतापूर्वक इंडेक्स नहीं हुआ। यह आमतौर पर एम्बेडर कॉन्फ़िगरेशन समस्या को दर्शाता है।", "fileWatcherStarted": "फाइल वॉचर शुरू हुआ।", "fileWatcherStopped": "फाइल वॉचर रुक गया।", @@ -64,7 +69,8 @@ "unknownError": "अज्ञात त्रुटि", "indexingRequiresWorkspace": "इंडेक्सिंग के लिए एक खुला वर्कस्पेस फ़ोल्डर आवश्यक है", "indexingStopped": "उपयोगकर्ता द्वारा इंडेक्सिंग रोकी गई।", - "indexingStoppedPartial": "इंडेक्सिंग रोकी गई। आंशिक इंडेक्स डेटा संरक्षित।" + "indexingStoppedPartial": "इंडेक्सिंग रोकी गई। आंशिक इंडेक्स डेटा संरक्षित।", + "cacheReadFailed": "कोड इंडेक्स कैश पढ़ा नहीं जा सका। मौजूदा इंडेक्स डेटा सुरक्षित रखा गया है। फिर से कोशिश करने के लिए इंडेक्सिंग दोबारा शुरू करो।" }, "semble": { "downloadingBinary": "semble बाइनरी डाउनलोड हो रहा है...", @@ -73,5 +79,6 @@ "downloadFailed": "semble डाउनलोड करने में विफल: {{errorMessage}}", "checkFailed": "Semble जाँच विफल: {{errorMessage}}", "providerReset": "Semble प्रदाता रीसेट किया गया। डिस्क कैश अगले रीबिल्ड तक बना रहता है।" - } + }, + "quotaExceeded": "एम्बेडिंग प्रदाता का कोटा या बिलिंग सीमा पूरी हो गई है। अपना प्लान या बिलिंग जाँचो, फिर इंडेक्सिंग दोबारा शुरू करो।" } diff --git a/src/i18n/locales/id/embeddings.json b/src/i18n/locales/id/embeddings.json index bba797d3e5..aae9e6b5eb 100644 --- a/src/i18n/locales/id/embeddings.json +++ b/src/i18n/locales/id/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Penyedia pencarian membatasi laju permintaan. Pengindeksan dilanjutkan secara otomatis setelah waktu tunggu penyedia berakhir.", "authenticationFailed": "Gagal membuat embeddings: Autentikasi gagal. Silakan periksa API key Anda.", "failedWithStatus": "Gagal membuat embeddings setelah {{attempts}} percobaan: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Gagal membuat embeddings setelah {{attempts}} percobaan: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Tidak dapat membuat layanan: Pengindeksan kode tidak dikonfigurasi dengan benar" }, "orchestrator": { + "providerWaiting": "Menunggu {{service}}; pengindeksan dilanjutkan secara otomatis", + "providerWaitingUntil": "Menunggu {{service}}; pengindeksan dilanjutkan secara otomatis pada {{time}}", + "indexingFilesFailed": "Masih ada kegagalan file. Tidak ada percobaan ulang otomatis yang tertunda. Indeks parsial tersedia.", + "indexingStopping": "Menghentikan pengindeksan...", "indexingFailedNoBlocks": "Pengindeksan gagal: Tidak ada blok kode yang berhasil diindeks. Ini biasanya menunjukkan masalah konfigurasi embedder.", "fileWatcherStarted": "Pemantau file dimulai.", "fileWatcherStopped": "Pemantau file dihentikan.", @@ -64,7 +69,8 @@ "unknownError": "Kesalahan tidak diketahui", "indexingRequiresWorkspace": "Pengindeksan memerlukan folder workspace yang terbuka", "indexingStopped": "Pengindeksan dihentikan oleh pengguna.", - "indexingStoppedPartial": "Pengindeksan dihentikan. Data indeks parsial dipertahankan." + "indexingStoppedPartial": "Pengindeksan dihentikan. Data indeks parsial dipertahankan.", + "cacheReadFailed": "Tidak dapat membaca cache indeks kode. Data indeks yang ada tetap disimpan. Mulai pengindeksan lagi untuk mencoba ulang." }, "semble": { "downloadingBinary": "Mengunduh biner semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Gagal mengunduh semble: {{errorMessage}}", "checkFailed": "Pemeriksaan semble gagal: {{errorMessage}}", "providerReset": "Penyedia Semble direset. Cache di disk tetap ada hingga pembangunan ulang berikutnya." - } + }, + "quotaExceeded": "Kuota atau batas penagihan penyedia embeddings telah tercapai. Periksa paket atau penagihanmu, lalu mulai pengindeksan lagi." } diff --git a/src/i18n/locales/it/embeddings.json b/src/i18n/locales/it/embeddings.json index 5b517df528..6c8516aebe 100644 --- a/src/i18n/locales/it/embeddings.json +++ b/src/i18n/locales/it/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Il provider di ricerca ha limitato la frequenza delle richieste. L’indicizzazione riprende automaticamente al termine dell’attesa del provider.", "authenticationFailed": "Creazione degli embedding non riuscita: Autenticazione fallita. Controlla la tua chiave API.", "failedWithStatus": "Creazione degli embedding non riuscita dopo {{attempts}} tentativi: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Creazione degli embedding non riuscita dopo {{attempts}} tentativi: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Impossibile creare i servizi: L'indicizzazione del codice non è configurata correttamente" }, "orchestrator": { + "providerWaiting": "In attesa di {{service}}; l’indicizzazione riprende automaticamente", + "providerWaitingUntil": "In attesa di {{service}}; l’indicizzazione riprende automaticamente alle {{time}}", + "indexingFilesFailed": "Persistono errori nei file. Nessun nuovo tentativo automatico in attesa. È disponibile un indice parziale.", + "indexingStopping": "Arresto dell'indicizzazione in corso...", "indexingFailedNoBlocks": "Indicizzazione fallita: Nessun blocco di codice è stato indicizzato con successo. Questo di solito indica un problema di configurazione dell'embedder.", "fileWatcherStarted": "Monitoraggio file avviato.", "fileWatcherStopped": "Monitoraggio file fermato.", @@ -64,7 +69,8 @@ "unknownError": "Errore sconosciuto", "indexingRequiresWorkspace": "L'indicizzazione richiede una cartella di workspace aperta", "indexingStopped": "Indicizzazione interrotta dall'utente.", - "indexingStoppedPartial": "Indicizzazione interrotta. Dati di indice parziali conservati." + "indexingStoppedPartial": "Indicizzazione interrotta. Dati di indice parziali conservati.", + "cacheReadFailed": "Impossibile leggere la cache dell'indice del codice. I dati dell'indice esistente sono stati conservati. Riavvia l'indicizzazione per riprovare." }, "semble": { "downloadingBinary": "Download del binario semble in corso...", @@ -73,5 +79,6 @@ "downloadFailed": "Download di semble fallito: {{errorMessage}}", "checkFailed": "Verifica di semble fallita: {{errorMessage}}", "providerReset": "Provider Semble ripristinato. La cache su disco rimane fino alla prossima ricostruzione." - } + }, + "quotaExceeded": "Quota o limite di fatturazione del provider di embedding raggiunto. Controlla il tuo piano o la fatturazione, poi riavvia l'indicizzazione." } diff --git a/src/i18n/locales/ja/embeddings.json b/src/i18n/locales/ja/embeddings.json index b90d9a0d52..935ca6cb09 100644 --- a/src/i18n/locales/ja/embeddings.json +++ b/src/i18n/locales/ja/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "検索プロバイダーのレート制限に達しました。プロバイダーの待機時間が終了すると、インデックス作成が自動的に再開されます。", "authenticationFailed": "埋め込みの作成に失敗しました:認証に失敗しました。APIキーを確認してください。", "failedWithStatus": "{{attempts}}回試行しましたが、埋め込みの作成に失敗しました:HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "{{attempts}}回試行しましたが、埋め込みの作成に失敗しました:{{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "サービスを作成できません: コードインデックスが正しく設定されていません" }, "orchestrator": { + "providerWaiting": "{{service}} を待機中です。インデックス作成は自動的に再開されます", + "providerWaitingUntil": "{{service}} を待機中です。インデックス作成は {{time}} に自動的に再開されます", + "indexingFilesFailed": "ファイルのエラーが残っています。自動再試行の予定はありません。部分的なインデックスを利用できます。", + "indexingStopping": "インデックス作成を停止中...", "indexingFailedNoBlocks": "インデックス作成に失敗しました:コードブロックが正常にインデックス化されませんでした。これは通常、エンベッダーの設定問題を示しています。", "fileWatcherStarted": "ファイルウォッチャーが開始されました。", "fileWatcherStopped": "ファイルウォッチャーが停止されました。", @@ -64,7 +69,8 @@ "unknownError": "不明なエラー", "indexingRequiresWorkspace": "インデックス作成には、開かれたワークスペースフォルダーが必要です", "indexingStopped": "ユーザーによりインデックス作成が停止されました。", - "indexingStoppedPartial": "インデックス作成が停止されました。部分的なインデックスデータは保持されています。" + "indexingStoppedPartial": "インデックス作成が停止されました。部分的なインデックスデータは保持されています。", + "cacheReadFailed": "コードインデックスのキャッシュを読み取れませんでした。既存のインデックスデータは保持されています。再試行するには、インデックス作成をもう一度開始してください。" }, "semble": { "downloadingBinary": "sembleバイナリをダウンロード中...", @@ -73,5 +79,6 @@ "downloadFailed": "sembleのダウンロードに失敗しました: {{errorMessage}}", "checkFailed": "Sembleの確認に失敗しました: {{errorMessage}}", "providerReset": "Sembleプロバイダーをリセットしました。次回の再構築までディスクキャッシュは残ります。" - } + }, + "quotaExceeded": "埋め込みプロバイダーのクォータまたは請求上限に達しました。プランまたは請求情報を確認してから、インデックス作成をもう一度開始してください。" } diff --git a/src/i18n/locales/ko/embeddings.json b/src/i18n/locales/ko/embeddings.json index 7307c91c82..9c3e75169d 100644 --- a/src/i18n/locales/ko/embeddings.json +++ b/src/i18n/locales/ko/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "검색 공급자의 요청 속도 제한에 도달했습니다. 공급자의 대기 시간이 끝나면 인덱싱이 자동으로 재개됩니다.", "authenticationFailed": "임베딩 생성 실패: 인증에 실패했습니다. API 키를 확인하세요.", "failedWithStatus": "{{attempts}}번 시도 후 임베딩 생성 실패: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "{{attempts}}번 시도 후 임베딩 생성 실패: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "서비스를 생성할 수 없습니다: 코드 인덱싱이 올바르게 구성되지 않았습니다" }, "orchestrator": { + "providerWaiting": "{{service}} 대기 중; 인덱싱이 자동으로 재개됩니다", + "providerWaitingUntil": "{{service}} 대기 중; {{time}}에 인덱싱이 자동으로 재개됩니다", + "indexingFilesFailed": "파일 오류가 남아 있습니다. 예정된 자동 재시도가 없습니다. 부분 인덱스를 사용할 수 있습니다.", + "indexingStopping": "인덱싱 중지 중...", "indexingFailedNoBlocks": "인덱싱 실패: 코드 블록이 성공적으로 인덱싱되지 않았습니다. 이는 일반적으로 임베더 구성 문제를 나타냅니다.", "fileWatcherStarted": "파일 감시자가 시작되었습니다.", "fileWatcherStopped": "파일 감시자가 중지되었습니다.", @@ -64,7 +69,8 @@ "unknownError": "알 수 없는 오류", "indexingRequiresWorkspace": "인덱싱에는 열린 워크스페이스 폴더가 필요합니다", "indexingStopped": "사용자에 의해 인덱싱이 중지되었습니다.", - "indexingStoppedPartial": "인덱싱이 중지되었습니다. 부분 인덱스 데이터가 보존되었습니다." + "indexingStoppedPartial": "인덱싱이 중지되었습니다. 부분 인덱스 데이터가 보존되었습니다.", + "cacheReadFailed": "코드 인덱스 캐시를 읽지 못했습니다. 기존 인덱스 데이터는 보존되었습니다. 다시 시도하려면 인덱싱을 다시 시작하세요." }, "semble": { "downloadingBinary": "semble 바이너리 다운로드 중...", @@ -73,5 +79,6 @@ "downloadFailed": "semble 다운로드 실패: {{errorMessage}}", "checkFailed": "Semble 확인 실패: {{errorMessage}}", "providerReset": "Semble 공급자가 재설정되었습니다. 디스크 캐시는 다음 재구축까지 유지됩니다." - } + }, + "quotaExceeded": "임베딩 공급자의 할당량 또는 결제 한도에 도달했습니다. 요금제나 결제 정보를 확인한 다음 인덱싱을 다시 시작하세요." } diff --git a/src/i18n/locales/nl/embeddings.json b/src/i18n/locales/nl/embeddings.json index f78c927e3a..a3e9459da6 100644 --- a/src/i18n/locales/nl/embeddings.json +++ b/src/i18n/locales/nl/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "De zoekprovider heeft het aantal aanvragen beperkt. De indexering wordt automatisch hervat na de wachttijd van de provider.", "authenticationFailed": "Insluitingen maken mislukt: Authenticatie mislukt. Controleer je API-sleutel.", "failedWithStatus": "Insluitingen maken mislukt na {{attempts}} pogingen: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Insluitingen maken mislukt na {{attempts}} pogingen: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Kan geen services maken: Code-indexering is niet correct geconfigureerd" }, "orchestrator": { + "providerWaiting": "Wachten op {{service}}; de indexering wordt automatisch hervat", + "providerWaitingUntil": "Wachten op {{service}}; de indexering wordt automatisch hervat om {{time}}", + "indexingFilesFailed": "Er zijn nog bestandsfouten. Geen automatische nieuwe pogingen in afwachting. Een gedeeltelijke index is beschikbaar.", + "indexingStopping": "Indexering wordt gestopt...", "indexingFailedNoBlocks": "Indexering mislukt: Geen codeblokken werden succesvol geïndexeerd. Dit duidt meestal op een embedder configuratieprobleem.", "fileWatcherStarted": "Bestandsmonitor gestart.", "fileWatcherStopped": "Bestandsmonitor gestopt.", @@ -64,7 +69,8 @@ "unknownError": "Onbekende fout", "indexingRequiresWorkspace": "Indexering vereist een geopende workspace map", "indexingStopped": "Indexering gestopt door gebruiker.", - "indexingStoppedPartial": "Indexering gestopt. Gedeeltelijke indexgegevens bewaard." + "indexingStoppedPartial": "Indexering gestopt. Gedeeltelijke indexgegevens bewaard.", + "cacheReadFailed": "De cache van de code-index kon niet worden gelezen. Bestaande indexgegevens zijn bewaard. Start de indexering opnieuw om het nog eens te proberen." }, "semble": { "downloadingBinary": "Semble-binary downloaden...", @@ -73,5 +79,6 @@ "downloadFailed": "Downloaden van semble mislukt: {{errorMessage}}", "checkFailed": "Semble-controle mislukt: {{errorMessage}}", "providerReset": "Semble-provider gereset. Schijfcache blijft behouden tot de volgende heropbouw." - } + }, + "quotaExceeded": "Het quotum of de factureringslimiet van de insluitingsprovider is bereikt. Controleer je abonnement of facturering en start de indexering opnieuw." } diff --git a/src/i18n/locales/pl/embeddings.json b/src/i18n/locales/pl/embeddings.json index 3f81081238..4517cd7a90 100644 --- a/src/i18n/locales/pl/embeddings.json +++ b/src/i18n/locales/pl/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Dostawca wyszukiwania ograniczył częstotliwość żądań. Indeksowanie zostanie automatycznie wznowione po upływie czasu oczekiwania dostawcy.", "authenticationFailed": "Nie udało się utworzyć osadzeń: Uwierzytelnianie nie powiodło się. Sprawdź swój klucz API.", "failedWithStatus": "Nie udało się utworzyć osadzeń po {{attempts}} próbach: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Nie udało się utworzyć osadzeń po {{attempts}} próbach: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Nie można utworzyć usług: Indeksowanie kodu nie jest poprawnie skonfigurowane" }, "orchestrator": { + "providerWaiting": "Oczekiwanie na {{service}}; indeksowanie zostanie automatycznie wznowione", + "providerWaitingUntil": "Oczekiwanie na {{service}}; indeksowanie zostanie automatycznie wznowione o {{time}}", + "indexingFilesFailed": "Nadal występują błędy plików. Brak oczekujących automatycznych ponowień. Dostępny jest częściowy indeks.", + "indexingStopping": "Zatrzymywanie indeksowania...", "indexingFailedNoBlocks": "Indeksowanie nie powiodło się: Żadne bloki kodu nie zostały pomyślnie zaindeksowane. To zwykle wskazuje na problem z konfiguracją embeddera.", "fileWatcherStarted": "Monitor plików uruchomiony.", "fileWatcherStopped": "Monitor plików zatrzymany.", @@ -64,7 +69,8 @@ "unknownError": "Nieznany błąd", "indexingRequiresWorkspace": "Indeksowanie wymaga otwartego folderu workspace", "indexingStopped": "Indeksowanie zatrzymane przez użytkownika.", - "indexingStoppedPartial": "Indeksowanie zatrzymane. Częściowe dane indeksu zachowane." + "indexingStoppedPartial": "Indeksowanie zatrzymane. Częściowe dane indeksu zachowane.", + "cacheReadFailed": "Nie udało się odczytać pamięci podręcznej indeksu kodu. Istniejące dane indeksu zostały zachowane. Uruchom indeksowanie ponownie, aby spróbować jeszcze raz." }, "semble": { "downloadingBinary": "Pobieranie binariów semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Nie udało się pobrać semble: {{errorMessage}}", "checkFailed": "Sprawdzenie semble nie powiodło się: {{errorMessage}}", "providerReset": "Dostawca Semble zresetowany. Pamięć podręczna na dysku pozostaje do kolejnej przebudowy." - } + }, + "quotaExceeded": "Osiągnięto limit wykorzystania lub rozliczeń u dostawcy osadzeń. Sprawdź swój plan lub rozliczenia, a następnie uruchom indeksowanie ponownie." } diff --git a/src/i18n/locales/pt-BR/embeddings.json b/src/i18n/locales/pt-BR/embeddings.json index fa9be50a42..e43d21bcc6 100644 --- a/src/i18n/locales/pt-BR/embeddings.json +++ b/src/i18n/locales/pt-BR/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "O provedor de pesquisa limitou a taxa de solicitações. A indexação é retomada automaticamente após o tempo de espera do provedor.", "authenticationFailed": "Falha ao criar embeddings: Falha na autenticação. Verifique sua chave de API.", "failedWithStatus": "Falha ao criar embeddings após {{attempts}} tentativas: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Falha ao criar embeddings após {{attempts}} tentativas: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Não é possível criar serviços: A indexação de código não está configurada corretamente" }, "orchestrator": { + "providerWaiting": "Aguardando {{service}}; a indexação é retomada automaticamente", + "providerWaitingUntil": "Aguardando {{service}}; a indexação é retomada automaticamente às {{time}}", + "indexingFilesFailed": "Ainda há falhas em arquivos. Nenhuma nova tentativa automática pendente. Um índice parcial está disponível.", + "indexingStopping": "Parando a indexação...", "indexingFailedNoBlocks": "Indexação falhou: Nenhum bloco de código foi indexado com sucesso. Isso geralmente indica um problema de configuração do embedder.", "fileWatcherStarted": "Monitor de arquivos iniciado.", "fileWatcherStopped": "Monitor de arquivos parado.", @@ -64,7 +69,8 @@ "unknownError": "Erro desconhecido", "indexingRequiresWorkspace": "A indexação requer uma pasta de workspace aberta", "indexingStopped": "Indexação interrompida pelo usuário.", - "indexingStoppedPartial": "Indexação interrompida. Dados de índice parciais preservados." + "indexingStoppedPartial": "Indexação interrompida. Dados de índice parciais preservados.", + "cacheReadFailed": "Não foi possível ler o cache do índice de código. Os dados do índice existente foram preservados. Inicie a indexação novamente para tentar de novo." }, "semble": { "downloadingBinary": "Baixando o binário do semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Falha ao baixar semble: {{errorMessage}}", "checkFailed": "Verificação do semble falhou: {{errorMessage}}", "providerReset": "Provedor Semble redefinido. O cache em disco permanece até a próxima reconstrução." - } + }, + "quotaExceeded": "A cota ou o limite de cobrança do provedor de embeddings foi atingido. Verifique seu plano ou sua cobrança e inicie a indexação novamente." } diff --git a/src/i18n/locales/ru/embeddings.json b/src/i18n/locales/ru/embeddings.json index 0a25f7cfda..1185f49c54 100644 --- a/src/i18n/locales/ru/embeddings.json +++ b/src/i18n/locales/ru/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Поисковый провайдер ограничил частоту запросов. Индексация возобновится автоматически после окончания периода ожидания провайдера.", "authenticationFailed": "Не удалось создать вложения: Ошибка аутентификации. Проверьте свой ключ API.", "failedWithStatus": "Не удалось создать вложения после {{attempts}} попыток: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Не удалось создать вложения после {{attempts}} попыток: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Невозможно создать сервисы: Индексация кода не настроена должным образом" }, "orchestrator": { + "providerWaiting": "Ожидание {{service}}; индексация возобновится автоматически", + "providerWaitingUntil": "Ожидание {{service}}; индексация возобновится автоматически в {{time}}", + "indexingFilesFailed": "Остались ошибки файлов. Автоматических повторных попыток не ожидается. Доступен частичный индекс.", + "indexingStopping": "Остановка индексации...", "indexingFailedNoBlocks": "Индексация не удалась: Ни один блок кода не был успешно проиндексирован. Это обычно указывает на проблему конфигурации эмбеддера.", "fileWatcherStarted": "Наблюдатель файлов запущен.", "fileWatcherStopped": "Наблюдатель файлов остановлен.", @@ -64,7 +69,8 @@ "unknownError": "Неизвестная ошибка", "indexingRequiresWorkspace": "Для индексации требуется открытая папка рабочего пространства", "indexingStopped": "Индексация остановлена пользователем.", - "indexingStoppedPartial": "Индексация остановлена. Частичные данные индекса сохранены." + "indexingStoppedPartial": "Индексация остановлена. Частичные данные индекса сохранены.", + "cacheReadFailed": "Не удалось прочитать кэш индекса кода. Существующие данные индекса сохранены. Запусти индексацию снова, чтобы повторить попытку." }, "semble": { "downloadingBinary": "Загрузка бинарного файла semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Не удалось загрузить semble: {{errorMessage}}", "checkFailed": "Проверка semble не удалась: {{errorMessage}}", "providerReset": "Провайдер Semble сброшен. Дисковый кеш сохраняется до следующей пересборки." - } + }, + "quotaExceeded": "Достигнута квота или лимит расходов у провайдера вложений. Проверь свой тариф или платёжные данные, затем запусти индексацию снова." } diff --git a/src/i18n/locales/tr/embeddings.json b/src/i18n/locales/tr/embeddings.json index 5202ccd220..cec2a5703e 100644 --- a/src/i18n/locales/tr/embeddings.json +++ b/src/i18n/locales/tr/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Arama sağlayıcısının istek hızı sınırına ulaşıldı. Sağlayıcının bekleme süresi dolunca dizinleme otomatik olarak devam eder.", "authenticationFailed": "Gömülmeler oluşturulamadı: Kimlik doğrulama başarısız oldu. Lütfen API anahtarınızı kontrol edin.", "failedWithStatus": "{{attempts}} denemeden sonra gömülmeler oluşturulamadı: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "{{attempts}} denemeden sonra gömülmeler oluşturulamadı: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Hizmetler oluşturulamıyor: Kod indeksleme düzgün yapılandırılmamış" }, "orchestrator": { + "providerWaiting": "{{service}} bekleniyor; dizinleme otomatik olarak devam eder", + "providerWaitingUntil": "{{service}} bekleniyor; dizinleme {{time}} zamanında otomatik olarak devam eder", + "indexingFilesFailed": "Dosya hataları devam ediyor. Bekleyen otomatik yeniden deneme yok. Kısmi indeks kullanılabilir.", + "indexingStopping": "İndeksleme durduruluyor...", "indexingFailedNoBlocks": "İndeksleme başarısız: Hiçbir kod bloğu başarıyla indekslenemedi. Bu genellikle bir embedder yapılandırma sorunu olduğunu gösterir.", "fileWatcherStarted": "Dosya izleyici başlatıldı.", "fileWatcherStopped": "Dosya izleyici durduruldu.", @@ -64,7 +69,8 @@ "unknownError": "Bilinmeyen hata", "indexingRequiresWorkspace": "İndeksleme açık bir workspace klasörü gerektirir", "indexingStopped": "İndeksleme kullanıcı tarafından durduruldu.", - "indexingStoppedPartial": "İndeksleme durduruldu. Kısmi indeks verileri korundu." + "indexingStoppedPartial": "İndeksleme durduruldu. Kısmi indeks verileri korundu.", + "cacheReadFailed": "Kod indeksi önbelleği okunamadı. Mevcut indeks verileri korundu. Yeniden denemek için indekslemeyi tekrar başlat." }, "semble": { "downloadingBinary": "semble ikili dosyası indiriliyor...", @@ -73,5 +79,6 @@ "downloadFailed": "semble indirilemedi: {{errorMessage}}", "checkFailed": "Semble kontrolü başarısız: {{errorMessage}}", "providerReset": "Semble sağlayıcısı sıfırlandı. Diskteki önbellek bir sonraki yeniden yapılandırmaya kadar korunur." - } + }, + "quotaExceeded": "Gömülme sağlayıcısının kotasına veya faturalandırma sınırına ulaşıldı. Planını veya faturalandırma bilgilerini kontrol et, ardından indekslemeyi tekrar başlat." } diff --git a/src/i18n/locales/vi/embeddings.json b/src/i18n/locales/vi/embeddings.json index e33413b023..db875d4a21 100644 --- a/src/i18n/locales/vi/embeddings.json +++ b/src/i18n/locales/vi/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "Nhà cung cấp tìm kiếm đã giới hạn tốc độ yêu cầu. Việc lập chỉ mục tự động tiếp tục sau khi hết thời gian chờ của nhà cung cấp.", "authenticationFailed": "Không thể tạo nhúng: Xác thực không thành công. Vui lòng kiểm tra khóa API của bạn.", "failedWithStatus": "Không thể tạo nhúng sau {{attempts}} lần thử: HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "Không thể tạo nhúng sau {{attempts}} lần thử: {{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "Không thể tạo dịch vụ: Lập chỉ mục mã không được cấu hình đúng cách" }, "orchestrator": { + "providerWaiting": "Đang chờ {{service}}; việc lập chỉ mục tự động tiếp tục", + "providerWaitingUntil": "Đang chờ {{service}}; việc lập chỉ mục tự động tiếp tục lúc {{time}}", + "indexingFilesFailed": "Vẫn còn tệp bị lỗi. Không có lần thử lại tự động nào đang chờ. Có thể dùng chỉ mục một phần.", + "indexingStopping": "Đang dừng lập chỉ mục...", "indexingFailedNoBlocks": "Lập chỉ mục thất bại: Không có khối mã nào được lập chỉ mục thành công. Điều này thường cho thấy vấn đề cấu hình embedder.", "fileWatcherStarted": "Trình theo dõi tệp đã khởi động.", "fileWatcherStopped": "Trình theo dõi tệp đã dừng.", @@ -64,7 +69,8 @@ "unknownError": "Lỗi không xác định", "indexingRequiresWorkspace": "Lập chỉ mục yêu cầu một thư mục workspace đang mở", "indexingStopped": "Lập chỉ mục đã bị dừng bởi người dùng.", - "indexingStoppedPartial": "Lập chỉ mục đã dừng. Dữ liệu chỉ mục một phần được bảo toàn." + "indexingStoppedPartial": "Lập chỉ mục đã dừng. Dữ liệu chỉ mục một phần được bảo toàn.", + "cacheReadFailed": "Không thể đọc bộ nhớ đệm của chỉ mục mã. Dữ liệu chỉ mục hiện có đã được giữ lại. Bắt đầu lập chỉ mục lại để thử lại." }, "semble": { "downloadingBinary": "Đang tải xuống tệp nhị phân semble...", @@ -73,5 +79,6 @@ "downloadFailed": "Tải xuống semble thất bại: {{errorMessage}}", "checkFailed": "Kiểm tra semble thất bại: {{errorMessage}}", "providerReset": "Nhà cung cấp Semble đã đặt lại. Bộ nhớ đệm trên đĩa vẫn còn cho đến lần xây dựng lại tiếp theo." - } + }, + "quotaExceeded": "Đã đạt hạn ngạch hoặc giới hạn thanh toán của nhà cung cấp nhúng. Kiểm tra gói dịch vụ hoặc thông tin thanh toán của bạn, rồi bắt đầu lập chỉ mục lại." } diff --git a/src/i18n/locales/zh-CN/embeddings.json b/src/i18n/locales/zh-CN/embeddings.json index f511bc67c4..f89bd2fb25 100644 --- a/src/i18n/locales/zh-CN/embeddings.json +++ b/src/i18n/locales/zh-CN/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "搜索提供商已限制请求速率。提供商的等待时间结束后,索引将自动恢复。", "authenticationFailed": "创建嵌入失败:身份验证失败。请检查您的 API 密钥。", "failedWithStatus": "尝试 {{attempts}} 次后创建嵌入失败:HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "尝试 {{attempts}} 次后创建嵌入失败:{{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "无法创建服务:代码索引未正确配置" }, "orchestrator": { + "providerWaiting": "正在等待 {{service}};索引将自动恢复", + "providerWaitingUntil": "正在等待 {{service}};索引将于 {{time}} 自动恢复", + "indexingFilesFailed": "仍有文件失败。没有待执行的自动重试。部分索引可用。", + "indexingStopping": "正在停止索引...", "indexingFailedNoBlocks": "索引失败:没有代码块被成功索引。这通常表示 Embedder 配置问题。", "fileWatcherStarted": "文件监控已启动。", "fileWatcherStopped": "文件监控已停止。", @@ -64,7 +69,8 @@ "unknownError": "未知错误", "indexingRequiresWorkspace": "索引需要打开的工作区文件夹", "indexingStopped": "用户已停止索引。", - "indexingStoppedPartial": "索引已停止。部分索引数据已保留。" + "indexingStoppedPartial": "索引已停止。部分索引数据已保留。", + "cacheReadFailed": "无法读取代码索引缓存。现有索引数据已保留。重新启动索引以重试。" }, "semble": { "downloadingBinary": "正在下载 semble 二进制文件...", @@ -73,5 +79,6 @@ "downloadFailed": "下载 semble 失败:{{errorMessage}}", "checkFailed": "Semble 检查失败:{{errorMessage}}", "providerReset": "Semble 提供程序已重置。磁盘缓存将保留至下次重建。" - } + }, + "quotaExceeded": "已达到嵌入提供商的配额或计费上限。检查套餐或计费信息,然后重新启动索引。" } diff --git a/src/i18n/locales/zh-TW/embeddings.json b/src/i18n/locales/zh-TW/embeddings.json index 6adef3c9de..756367e912 100644 --- a/src/i18n/locales/zh-TW/embeddings.json +++ b/src/i18n/locales/zh-TW/embeddings.json @@ -1,4 +1,5 @@ { + "searchProviderRateLimited": "搜尋供應商已限制請求速率。供應商的等待時間結束後,索引將自動恢復。", "authenticationFailed": "建立內嵌失敗:驗證失敗。請檢查您的 API 金鑰。", "failedWithStatus": "嘗試 {{attempts}} 次後建立內嵌失敗:HTTP {{statusCode}} - {{errorMessage}}", "failedWithError": "嘗試 {{attempts}} 次後建立內嵌失敗:{{errorMessage}}", @@ -57,6 +58,10 @@ "codeIndexingNotConfigured": "無法建立服務:程式碼索引未正確設定" }, "orchestrator": { + "providerWaiting": "正在等待 {{service}};索引將自動恢復", + "providerWaitingUntil": "正在等待 {{service}};索引將於 {{time}} 自動恢復", + "indexingFilesFailed": "仍有檔案失敗。沒有待執行的自動重試。部分索引可供使用。", + "indexingStopping": "正在停止索引...", "indexingFailedNoBlocks": "索引失敗:沒有程式碼區塊被成功索引。這通常表示 Embedder 設定問題。", "fileWatcherStarted": "檔案監控已啟動。", "fileWatcherStopped": "檔案監控已停止。", @@ -64,7 +69,8 @@ "unknownError": "未知錯誤", "indexingRequiresWorkspace": "索引需要開啟的工作區資料夾", "indexingStopped": "使用者已停止索引。", - "indexingStoppedPartial": "索引已停止。部分索引資料已保留。" + "indexingStoppedPartial": "索引已停止。部分索引資料已保留。", + "cacheReadFailed": "無法讀取程式碼索引快取。現有索引資料已保留。重新啟動索引以重試。" }, "semble": { "downloadingBinary": "正在下載 semble 二進位檔...", @@ -73,5 +79,6 @@ "downloadFailed": "下載 semble 失敗:{{errorMessage}}", "checkFailed": "Semble 檢查失敗:{{errorMessage}}", "providerReset": "Semble 提供者已重設。磁碟快取將保留至下次重建。" - } + }, + "quotaExceeded": "已達內嵌供應商的配額或計費上限。檢查方案或帳務資訊,然後重新啟動索引。" } diff --git a/src/package.json b/src/package.json index 6c59828b1d..98578ff92a 100644 --- a/src/package.json +++ b/src/package.json @@ -493,7 +493,7 @@ "@lmstudio/sdk": "^1.1.1", "@mistralai/mistralai": "^1.9.18", "@modelcontextprotocol/sdk": "1.32.2", - "@qdrant/js-client-rest": "^1.14.0", + "@qdrant/js-client-rest": "1.20.0", "@roo-code/cloud": "workspace:^", "@roo-code/core": "workspace:^", "@roo-code/ipc": "workspace:^", diff --git a/src/services/code-index/__tests__/cache-manager.spec.ts b/src/services/code-index/__tests__/cache-manager.spec.ts index 77bd5be198..603a027784 100644 --- a/src/services/code-index/__tests__/cache-manager.spec.ts +++ b/src/services/code-index/__tests__/cache-manager.spec.ts @@ -2,7 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { createHash } from "crypto" import debounce from "lodash.debounce" -import { CacheManager } from "../cache-manager" +import { CacheManager, CacheReadError } from "../cache-manager" import { clearAllMocks } from "../../../test-utils/reset" @@ -11,6 +11,8 @@ vitest.mock("../../../utils/safeWriteJson", () => ({ safeWriteJson: vitest.fn().mockResolvedValue(undefined), })) +vitest.mock("../../../i18n", () => ({ t: (key: string) => key })) + // Import the mocked version import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -95,13 +97,31 @@ describe("CacheManager", () => { expect(cacheManager.getAllHashes()).toEqual(mockCache) }) - it("should handle missing cache file by creating empty cache", async () => { - ;(vscode.workspace.fs.readFile as Mock).mockRejectedValue(new Error("File not found")) - + it.each([ + ["missing (ENOENT)", () => Promise.reject(Object.assign(new Error("gone"), { code: "ENOENT" }))], + [ + "missing (FileNotFound)", + () => Promise.reject(Object.assign(new Error("gone"), { code: "FileNotFound" })), + ], + ["invalid JSON", () => Promise.resolve(Buffer.from("{not json"))], + ["a non-object", () => Promise.resolve(Buffer.from("[1, 2]"))], + ["non-string hashes", () => Promise.resolve(Buffer.from('{"a.ts": 3}'))], + ])("CI-CM-01: treats a %s cache as empty", async (_label, read) => { + ;(vscode.workspace.fs.readFile as Mock).mockImplementation(read) await cacheManager.initialize() - expect(cacheManager.getAllHashes()).toEqual({}) }) + + it("CI-CM-01: an unreadable cache throws CacheReadError and keeps nothing in memory changed", async () => { + cacheManager.updateHash("kept.ts", "hash") + const denied = Object.assign(new Error("denied"), { code: "EACCES" }) + ;(vscode.workspace.fs.readFile as Mock).mockRejectedValue(denied) + const error = await cacheManager.initialize().catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(CacheReadError) + expect((error as CacheReadError).cause).toBe(denied) + expect(cacheManager.getAllHashes()).toEqual({ "kept.ts": "hash" }) + expect(safeWriteJson).not.toHaveBeenCalledWith(expect.anything(), {}) + }) }) describe("hash management", () => { diff --git a/src/services/code-index/__tests__/code-index-scan-executor.spec.ts b/src/services/code-index/__tests__/code-index-scan-executor.spec.ts index bcc63f7c00..3fae7f607b 100644 --- a/src/services/code-index/__tests__/code-index-scan-executor.spec.ts +++ b/src/services/code-index/__tests__/code-index-scan-executor.spec.ts @@ -107,52 +107,29 @@ describe("CodeIndexScanExecutor", () => { }, ) - it.each([ - { found: 0, indexed: 0, error: false, message: undefined }, - { found: 3, indexed: 0, error: false, message: "embeddings:orchestrator.indexingFailedNoBlocks" }, - { found: 3, indexed: 0, error: true, message: "Indexing failed: batch failure" }, - { found: 0, indexed: 0, error: true, message: "Indexing failed completely: batch failure" }, - { found: 10, indexed: 9, error: true, message: undefined }, - { - found: 10, - indexed: 8, - error: true, - message: "Indexing partially failed: Only 8 of 10 blocks were indexed. batch failure", - }, - { found: 10, indexed: 8, error: false, message: undefined }, - ])( - "preserves full-scan policy for $indexed/$found blocks, error=$error", - async ({ found, indexed, error, message }) => { + const scanCases = (["runFullScan", "runIncrementalScan"] as const).flatMap((method) => + [ + { found: 0, indexed: 0, errors: 0, partial: false }, + { found: 0, indexed: 0, errors: 1, partial: true }, + { found: 3, indexed: 3, errors: 0, partial: false }, + { found: 3, indexed: 0, errors: 3, partial: true }, + { found: 10, indexed: 1, errors: 9, partial: true }, + { found: 10, indexed: 8, errors: 0, partial: true }, + ].map((scanCase) => ({ method, ...scanCase })), + ) + + it.each(scanCases)( + "$method completes with a partial index for $indexed/$found blocks and $errors file failures", + async ({ method, found, indexed, errors, partial }) => { const { executor, scanner } = setup() scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => { onParsed?.(found) onIndexed?.(indexed) - if (error) onError?.(new Error("batch failure")) + for (let iter = 0; iter < errors; iter++) onError?.(new Error("file failure")) return { stats: { processed: 1, skipped: 0 }, totalBlockCount: found } }) - const result = executor.runFullScan(new AbortController().signal) - if (message) await expect(result).rejects.toThrow(message) - else await expect(result).resolves.toBe(true) + await expect(executor[method](new AbortController().signal)).resolves.toBe(true) + expect(executor.partial).toBe(partial) }, ) - - it.each([0, 2])("rejects incremental batch errors with %s indexed blocks", async (indexed) => { - const { executor, scanner } = setup() - const failures = [new Error("embedding failure"), new Error("upsert failure"), new Error("embedding failure")] - scanner.scanDirectory.mockImplementation(async (_path, onError, onIndexed, onParsed) => { - onParsed?.(3) - onIndexed?.(indexed) - for (const failure of failures) onError?.(failure) - return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 } - }) - const result = executor.runIncrementalScan(new AbortController().signal) - await expect(result).rejects.toBeInstanceOf(AggregateError) - await expect(result).rejects.toMatchObject({ - errors: failures, - message: "Incremental scan failed with 3 errors:\nembedding failure\nupsert failure", - }) - await result.catch((error: AggregateError) => { - for (const [index, failure] of failures.entries()) expect(error.errors[index]).toBe(failure) - }) - }) }) diff --git a/src/services/code-index/__tests__/code-index-status-manager.spec.ts b/src/services/code-index/__tests__/code-index-status-manager.spec.ts index 6d003194a6..2a0d454a60 100644 --- a/src/services/code-index/__tests__/code-index-status-manager.spec.ts +++ b/src/services/code-index/__tests__/code-index-status-manager.spec.ts @@ -39,6 +39,9 @@ function makeSource(workspacePath: string) { workspacePath, workspaceEnabled: true, autoEnableDefault: true, + failedFiles: [], + failedFileSummary: undefined, + providerWaits: [], } let emit = () => {} const subscription = { dispose: vi.fn() } @@ -168,6 +171,43 @@ describe("CodeIndexStatusManager", () => { manager.dispose() }) + it.each(["Error", "Stopping", "Standby", "Indexed"] as const)( + "publishes computed %s and failures rather than the event payload", + (systemStatus) => { + const source = makeSource("/first") + const publish = vi.fn() + const manager = new CodeIndexStatusManager(() => source) + manager["publishStatus"] = publish + const computed = { + ...source.status, + systemStatus, + message: "Computed manager status", + failedFiles: [ + { + path: "failed.ts", + revision: "version", + activity: "embed" as const, + reason: "unavailable", + classification: "server" as const, + attempt: 3, + state: "exhausted" as const, + }, + ], + } + source.getCurrentStatus.mockReturnValue(computed) + try { + manager.init() + expect(publish).toHaveBeenCalledExactlyOnceWith(computed) + source.status.systemStatus = "Indexing" + source.emit() + expect(publish).toHaveBeenLastCalledWith(computed) + expect(publish).toHaveBeenCalledTimes(2) + } finally { + manager.dispose() + } + }, + ) + it("publishes initial and full updated status without resubscribing in the same workspace", () => { const source = makeSource("/first") const publish = vi.fn() @@ -177,6 +217,7 @@ describe("CodeIndexStatusManager", () => { manager.init() expect(publish).toHaveBeenCalledExactlyOnceWith(source.status) source.status.message = "Updated" + source.status.providerWaits = [{ service: "embedding", resumeAt: 123_000, waitingFiles: ["waiting.ts"] }] source.emit() expect(publish).toHaveBeenLastCalledWith(source.status) expect(publish).toHaveBeenCalledTimes(2) diff --git a/src/services/code-index/__tests__/file-failure-registry.spec.ts b/src/services/code-index/__tests__/file-failure-registry.spec.ts new file mode 100644 index 0000000000..224a14fa2c --- /dev/null +++ b/src/services/code-index/__tests__/file-failure-registry.spec.ts @@ -0,0 +1,73 @@ +import { FileFailureRegistry } from "../file-failure-registry" + +describe("FileFailureRegistry", () => { + it("CI-FR-01/02: batches of one revision share a budget; a new revision or reset refills it", () => { + const registry = new FileFailureRegistry() + registry.renew("a.ts", "one") + registry.renew("b.ts", "one") + const budget = registry.getRetryBudget("a.ts", "one", "embed") + budget.retriesUsed = 2 + expect(registry.getRetryBudget("a.ts", "one", "embed")).toBe(budget) + expect(registry.getRetryBudget("a.ts", "one", "upsert").retriesUsed).toBe(0) + registry.renew("b.ts", "two") + expect(registry.getRetryBudget("a.ts", "one", "embed").retriesUsed).toBe(2) + registry.renew("a.ts", "two") + expect(registry.getRetryBudget("a.ts", "two", "embed").retriesUsed).toBe(0) + registry.getRetryBudget("a.ts", "two", "embed").retriesUsed = 1 + registry.resetAllBudgets() + expect(registry.getRetryBudget("a.ts", "two", "embed").retriesUsed).toBe(0) + registry.getRetryBudget("a.ts", "two", "embed").retriesUsed = 1 + registry.clear("a.ts", "two") + expect(registry.getRetryBudget("a.ts", "two", "embed").retriesUsed).toBe(0) + }) + + it("keeps a displayed entry across a reset and a new revision until it is cleared", () => { + const registry = new FileFailureRegistry() + registry.renew("a.ts", "one") + registry.record("a.ts", "one", "upsert", "server failed", 3, "exhausted", "server") + registry.resetAllBudgets() + registry.renew("a.ts", "two") + expect(registry.list()).toEqual([ + expect.objectContaining({ path: "a.ts", revision: "two", state: "exhausted" }), + ]) + registry.clear("a.ts", "two") + expect(registry.list()).toEqual([]) + }) + + it("rejects stale records and clearing after a path is renewed", () => { + const registry = new FileFailureRegistry() + registry.renew("a.ts", "old") + registry.record("a.ts", "old", "embed", "failed", 1, "retrying", "connection") + registry.renew("a.ts", "new") + expect(registry.isCurrent("a.ts", "old")).toBe(false) + registry.record("a.ts", "new", "parse", "new failure", 1, "exhausted") + registry.record("a.ts", "old", "delete", "late failure", 3, "exhausted") + registry.clear("a.ts", "old") + expect(registry.list()[0]).toMatchObject({ revision: "new", activity: "parse", reason: "new failure" }) + }) + + it("signals each change and returns detached details", () => { + const registry = new FileFailureRegistry() + const changed = vi.fn() + const subscription = registry.onDidChange(changed) + registry.renew("a.ts", "hash") + registry.record("a.ts", "hash", "upsert", "busy", 1, "retrying", "server") + registry.record("a.ts", "hash", "upsert", "busy", 2, "retrying", "server") + expect(changed).toHaveBeenCalledTimes(2) + registry.list()[0].reason = "mutated" + expect(registry.list()[0].reason).toBe("busy") + subscription.dispose() + registry.clear("a.ts") + expect(changed).toHaveBeenCalledTimes(2) + }) + + it("CI-FR-03: a success clears the entry for that path only", () => { + const registry = new FileFailureRegistry() + registry.renew("a.ts", "one") + registry.renew("b.ts", "one") + registry.record("a.ts", "one", "read", "denied", 1, "exhausted") + registry.record("b.ts", "one", "parse", "bad", 1, "exhausted") + registry.clear("a.ts", "one") + expect(registry.list().map((failure) => failure.path)).toEqual(["b.ts"]) + }) +}) diff --git a/src/services/code-index/__tests__/file-index-writer.spec.ts b/src/services/code-index/__tests__/file-index-writer.spec.ts new file mode 100644 index 0000000000..45780ed34a --- /dev/null +++ b/src/services/code-index/__tests__/file-index-writer.spec.ts @@ -0,0 +1,173 @@ +import { FileIndexWriter } from "../file-index-writer" +import { FileFailureRegistry } from "../file-failure-registry" +import { ProviderPacerRegistry } from "../provider-pacer" +import type { CodeBlock, IEmbedder, IVectorStore } from "../interfaces" + +vi.mock("../embedders/cancellation", () => ({ waitForRetry: vi.fn(() => Promise.resolve()) })) + +const workspace = "/ws" +const block = (line: number): CodeBlock => ({ + file_path: "/ws/a.ts", + identifier: null, + type: "function", + start_line: line, + end_line: line + 1, + content: `code ${line}`, + fileHash: "h1", + segmentHash: `s${line}`, +}) +const serverError = () => Object.assign(new Error("server down"), { status: 503 }) + +function setup() { + const embedder = { + rateLimitScope: "openai:1", + createEmbeddings: vi.fn(async (texts: string[]) => ({ embeddings: texts.map(() => [0.1]) })), + } + const store = { + rateLimitScope: "qdrant:1", + upsertPoints: vi.fn(async () => {}), + deletePointsByFilePath: vi.fn(async () => {}), + } + const cache = { getHash: vi.fn((): string | undefined => "old"), updateHash: vi.fn(), deleteHash: vi.fn() } + const failures = new FileFailureRegistry() + const pacers = new ProviderPacerRegistry() + const run = vi.spyOn(pacers.get("openai:1"), "run") + // The doubles provide only the embedder and store members the writer calls. + const writer = new FileIndexWriter( + embedder as unknown as IEmbedder, + store as unknown as IVectorStore, + cache, + failures, + pacers, + workspace, + 1, + ) + return { writer, embedder, store, cache, failures, run } +} + +describe("FileIndexWriter", () => { + it("CI-WR-01: success updates the hash and clears the entry", async () => { + const { writer, cache, failures, store } = setup() + failures.renew("a.ts", "h1") + failures.record("a.ts", "h1", "read", "old", 1, "exhausted") + await expect(writer.replace("/ws/a.ts", "h1", [block(1)])).resolves.toBe(true) + expect(cache.updateHash).toHaveBeenCalledWith("/ws/a.ts", "h1") + expect(failures.list()).toEqual([]) + const [[points]] = store.upsertPoints.mock.calls as unknown as [[Array<{ payload: { filePath: string } }>]] + expect(points[0].payload.filePath).toBe("a.ts") + }) + + it("gives segments that share a start line distinct point ids", async () => { + const { writer, store } = setup() + await writer.replace("/ws/a.ts", "h1", [block(3), { ...block(3), content: "tail", segmentHash: "other" }]) + const ids = (store.upsertPoints.mock.calls as unknown as Array<[Array<{ id: string }>]>).map( + ([points]) => points[0].id, + ) + expect(new Set(ids).size).toBe(2) + }) + + it.each([ + ["a cached file", "old", 1], + ["a new file", undefined, 0], + ])("deletes old points only for %s", async (_name, cachedHash, deletes) => { + const { writer, store, cache } = setup() + cache.getHash.mockReturnValue(cachedHash) + await expect(writer.replace("/ws/a.ts", "h1", [block(1)])).resolves.toBe(true) + expect(store.deletePointsByFilePath).toHaveBeenCalledTimes(deletes) + }) + + it.each(["embed", "upsert"] as const)( + "CI-WR-02: exhausted %s records the activity and keeps the hash", + async (activity) => { + const context = setup() + if (activity === "embed") context.embedder.createEmbeddings.mockRejectedValue(serverError()) + else context.store.upsertPoints.mockRejectedValue(serverError()) + await expect(context.writer.replace("/ws/a.ts", "h1", [block(1)])).resolves.toBe(false) + expect(context.cache.updateHash).not.toHaveBeenCalled() + expect(context.failures.list()).toEqual([ + expect.objectContaining({ path: "a.ts", activity, state: "exhausted", attempt: 3 }), + ]) + }, + ) + + it("CI-WR-02: removal failure is recorded as delete and keeps the hash", async () => { + const { writer, store, cache, failures } = setup() + store.deletePointsByFilePath.mockRejectedValue(serverError()) + await expect(writer.remove("/ws/a.ts")).resolves.toBe(false) + expect(cache.deleteHash).not.toHaveBeenCalled() + expect(failures.list()).toEqual([ + expect.objectContaining({ path: "a.ts", activity: "delete", state: "exhausted" }), + ]) + }) + + it("CI-WR-03: each embed attempt goes through the pacer exactly once", async () => { + const { writer, embedder, run } = setup() + embedder.createEmbeddings.mockRejectedValueOnce(serverError()) + await writer.replace("/ws/a.ts", "h1", [block(1), block(5)]) + expect(embedder.createEmbeddings).toHaveBeenCalledTimes(3) + expect(run).toHaveBeenCalledTimes(3) + }) + + it("CI-WR-04: writes nothing to the registry or cache after the signal aborts", async () => { + const { writer, embedder, cache, failures } = setup() + const controller = new AbortController() + embedder.createEmbeddings.mockImplementation(async () => { + controller.abort() + throw serverError() + }) + // Read the reason after the call: it is undefined until the embed aborts the controller. + const outcome = await writer + .replace("/ws/a.ts", "h1", [block(1)], controller.signal) + .catch((error: unknown) => error) + expect(outcome).toBe(controller.signal.reason) + expect(cache.updateHash).not.toHaveBeenCalled() + expect(failures.list()).toEqual([]) + }) + + it("drops the old hash when a stop lands while the delete is in flight", async () => { + const { writer, store, cache, failures } = setup() + const controller = new AbortController() + store.deletePointsByFilePath.mockImplementation(async () => controller.abort()) + const outcome = await writer + .replace("/ws/a.ts", "h1", [block(1)], controller.signal) + .catch((error: unknown) => error) + expect(outcome).toBe(controller.signal.reason) + expect(cache.deleteHash).toHaveBeenCalledExactlyOnceWith("/ws/a.ts") + expect(cache.updateHash).not.toHaveBeenCalled() + expect(failures.list()).toEqual([]) + }) + + it("drops the old hash once its points are deleted, so a failed replacement is re-indexed", async () => { + const { writer, embedder, store, cache } = setup() + embedder.createEmbeddings.mockRejectedValue(Object.assign(new Error("bad"), { status: 400 })) + await expect(writer.replace("/ws/a.ts", "h1", [block(1)])).resolves.toBe(false) + expect(store.deletePointsByFilePath).toHaveBeenCalledOnce() + expect(cache.deleteHash).toHaveBeenCalledWith("/ws/a.ts") + expect(cache.updateHash).not.toHaveBeenCalled() + }) + + it("deletes partial points from a failed first attempt that stored no hash", async () => { + const { writer, store, cache, failures } = setup() + cache.getHash.mockReturnValue(undefined) + store.upsertPoints + .mockResolvedValueOnce(undefined) + .mockRejectedValue(Object.assign(new Error("bad"), { status: 400 })) + const blocks = Array.from({ length: 4 }, (_unused, index) => block(index + 1)) + await expect(writer.replace("/ws/a.ts", "h1", blocks)).resolves.toBe(false) + expect(store.deletePointsByFilePath).not.toHaveBeenCalled() + expect(failures.list()).toHaveLength(1) + + store.upsertPoints.mockResolvedValue(undefined) + await expect(writer.replace("/ws/a.ts", "h2", [block(1)])).resolves.toBe(true) + expect(store.deletePointsByFilePath).toHaveBeenCalledOnce() + }) + + it("removal success deletes the hash and clears prior failures", async () => { + const { writer, cache, failures } = setup() + failures.renew("a.ts", "h1") + failures.record("a.ts", "h1", "embed", "old", 3, "exhausted") + await expect(writer.remove("/ws/a.ts")).resolves.toBe(true) + expect(cache.deleteHash).toHaveBeenCalledWith("/ws/a.ts") + expect(failures.list()).toEqual([]) + }) +}) diff --git a/src/services/code-index/__tests__/manager.reliability.spec.ts b/src/services/code-index/__tests__/manager.reliability.spec.ts new file mode 100644 index 0000000000..0b59827277 --- /dev/null +++ b/src/services/code-index/__tests__/manager.reliability.spec.ts @@ -0,0 +1,287 @@ +import * as path from "path" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import type * as vscode from "vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexStateManager } from "../state-manager" +import { CacheManager, CacheReadError } from "../cache-manager" +import type { CodeIndexOrchestrator } from "../orchestrator" + +vi.mock("vscode", () => { + const emitter = function () { + const listeners = new Set<(value: unknown) => void>() + return { + event: (listener: (value: unknown) => void) => { + listeners.add(listener) + return { dispose: () => listeners.delete(listener) } + }, + fire: (value: unknown) => listeners.forEach((listener) => listener(value)), + dispose: () => listeners.clear(), + } + } + return { + EventEmitter: vi.fn().mockImplementation(emitter), + Uri: { joinPath: vi.fn(() => ({ fsPath: "/storage/cache.json" })) }, + workspace: { fs: { readFile: vi.fn() } }, + } +}) +vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { captureEvent: vi.fn() } } })) +const replacementOrder = vi.hoisted(() => [] as string[]) +vi.mock("../semble", () => ({ + SembleProvider: vi.fn().mockImplementation(function () { + replacementOrder.push("recreate") + return { initialize: vi.fn(async () => {}), dispose: vi.fn(), stopIndexing: vi.fn() } + }), +})) +vi.mock("../../../i18n", () => ({ t: (key: string) => key })) +vi.mock("../config-manager", () => ({ + CodeIndexConfigManager: vi.fn().mockImplementation(function (contextProxy: unknown) { + return { + isFeatureEnabled: true, + isFeatureConfigured: true, + currentEmbedderProvider: providerIdentifiers.openai, + loadConfiguration: async () => ({ requiresRestart: false }), + getContextProxy: () => contextProxy, + } + }), +})) + +const workspacePath = path.join(path.sep, "test", "workspace") + +type OrchestratorDouble = Pick + +function makeManager() { + const store = new Map() + const memento = { + get: (key: string, fallback?: unknown) => (store.has(key) ? store.get(key) : fallback), + update: async (key: string, value: unknown) => void store.set(key, value), + } + // The double provides only the context members the manager and cache manager read. + const context = { + workspaceState: memento, + globalState: memento, + globalStorageUri: { fsPath: "/storage" }, + } as unknown as vscode.ExtensionContext + const folderUri = { toString: () => `file://${workspacePath}` } as unknown as vscode.Uri + const stateManager = new CodeIndexStateManager() + const manager = new CodeIndexManager(workspacePath, folderUri, context, stateManager) + // Feature enablement is read from the config manager; these tests only need it to report enabled. + manager["_configManager"] = { + isFeatureEnabled: true, + isFeatureConfigured: true, + } as unknown as (typeof manager)["_configManager"] + manager["_cacheManager"] = {} as unknown as CacheManager + manager["_searchService"] = {} as unknown as (typeof manager)["_searchService"] + return { manager, stateManager } +} + +function attach(manager: CodeIndexManager, orchestrator: OrchestratorDouble) { + // The double provides only start/stop/isScanning/state. + manager["_orchestrator"] = orchestrator as unknown as CodeIndexOrchestrator +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => (resolve = done)) + return { promise, resolve } +} + +describe("CodeIndexManager reliability", () => { + it("CI-MG-01: a repeated start joins the running start", async () => { + const { manager } = makeManager() + const running = deferred() + const orchestrator: OrchestratorDouble = { + startIndexing: vi.fn(() => running.promise), + stopIndexing: vi.fn(async () => {}), + isScanning: true, + state: "Indexing", + } + attach(manager, orchestrator) + + const first = manager.startIndexing() + const second = manager.startIndexing() + running.resolve() + await Promise.all([first, second]) + expect(orchestrator.startIndexing).toHaveBeenCalledOnce() + }) + + it("CI-MG-01: a start while stopping waits for the stop, then starts", async () => { + const { manager } = makeManager() + const stopping = deferred() + const order: string[] = [] + let stopPromise: Promise | undefined + const orchestrator: OrchestratorDouble = { + // Mirrors the orchestrator contract: a start while stopping awaits the stop first. + startIndexing: vi.fn(async () => { + if (stopPromise) await stopPromise + order.push("start") + }), + stopIndexing: vi.fn(() => { + stopPromise = stopping.promise.then(() => void order.push("stopped")) + return stopPromise + }), + isScanning: false, + state: "Stopping", + } + attach(manager, orchestrator) + + const stop = manager.stopIndexing() + const start = manager.startIndexing() + stopping.resolve() + await Promise.all([stop, start]) + expect(order).toEqual(["stopped", "start"]) + }) + + it.each(["handleSettingsChange", "recoverFromError"] as const)( + "CI-MG-02: %s stops the scan and watcher before services are recreated", + async (entry) => { + const { manager } = makeManager() + replacementOrder.length = 0 + // The stop settles a tick later, so replacement work that does not await it would run first. + const orchestrator: OrchestratorDouble = { + startIndexing: vi.fn(async () => {}), + stopIndexing: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + replacementOrder.push("stop") + }), + isScanning: false, + state: "Indexed", + } + attach(manager, orchestrator) + manager["_configManager"] = { + isFeatureEnabled: true, + isFeatureConfigured: true, + currentEmbedderProvider: "semble", + loadConfiguration: async () => ({ requiresRestart: true }), + } as unknown as (typeof manager)["_configManager"] + + await manager[entry]() + + expect(replacementOrder[0]).toBe("stop") + if (entry === "handleSettingsChange") expect(replacementOrder).toEqual(["stop", "recreate"]) + else expect(manager["_orchestrator"]).toBeUndefined() + }, + ) + + it("CI-MG-02: one start from Error recovers, re-initializes and starts indexing", async () => { + const { manager, stateManager } = makeManager() + const contextProxy = {} + manager["_configManager"] = { + isFeatureEnabled: true, + isFeatureConfigured: true, + getContextProxy: () => contextProxy, + } as unknown as (typeof manager)["_configManager"] + const stale: OrchestratorDouble = { + startIndexing: vi.fn(async () => {}), + // Settles a tick later, as the real stop does, so a same-tick caller check would miss recovery. + stopIndexing: vi.fn(() => new Promise((resolve) => setTimeout(resolve, 0))), + isScanning: false, + state: "Error", + } + attach(manager, stale) + stateManager.setSystemState("Error", "scan failed") + const fresh: OrchestratorDouble = { + startIndexing: vi.fn(async () => void stateManager.setSystemState("Indexing", "scanning")), + stopIndexing: vi.fn(async () => {}), + isScanning: false, + state: "Standby", + } + vi.spyOn(CacheManager.prototype, "initialize").mockResolvedValue(undefined) + vi.spyOn(manager as unknown as { _recreateServices(): Promise }, "_recreateServices").mockImplementation( + async () => { + attach(manager, fresh) + manager["_searchService"] = {} as unknown as (typeof manager)["_searchService"] + }, + ) + + await manager.startIndexing() + + expect(stale.stopIndexing).toHaveBeenCalledOnce() + expect(stale.startIndexing).not.toHaveBeenCalled() + expect(fresh.startIndexing).toHaveBeenCalledOnce() + expect(manager.isInitialized).toBe(true) + expect(stateManager.state).toBe("Indexing") + }) + + it("CI-MG-02: a replacement failure ends in Error and stays restartable", async () => { + const { manager, stateManager } = makeManager() + manager["_configManager"] = { + isFeatureEnabled: true, + isFeatureConfigured: true, + loadConfiguration: async () => ({ requiresRestart: true }), + } as unknown as (typeof manager)["_configManager"] + vi.spyOn(manager as unknown as { _recreateServices(): Promise }, "_recreateServices").mockRejectedValue( + new Error("bad config"), + ) + await expect(manager.handleSettingsChange()).rejects.toThrow("bad config") + expect(stateManager.state).toBe("Error") + }) + + describe("CI-MG-03: status precedence", () => { + const limited = Object.assign(new Error("limited"), { status: 429, retryAfterMs: 60_000 }) + + async function withWait(manager: CodeIndexManager) { + const controller = new AbortController() + const pending = manager["pacers"] + .get("openai:1") + .run("w.ts", controller.signal, () => Promise.reject(limited)) + .catch(() => {}) + await new Promise((resolve) => setTimeout(resolve, 0)) + return async () => { + controller.abort() + await pending + } + } + + function withFailure(manager: CodeIndexManager) { + manager["failureRegistry"].renew("f.ts", "h") + manager["failureRegistry"].record("f.ts", "h", "embed", "down", 3, "exhausted") + } + + it.each([ + { base: "Error", wait: true, failure: true, expected: "Error" }, + { base: "Stopping", wait: true, failure: true, expected: "Stopping" }, + { base: "Indexed", wait: true, failure: true, expected: "Indexing" }, + { base: "Indexing", wait: false, failure: true, expected: "Indexing" }, + { base: "Indexed", wait: false, failure: true, expected: "Standby" }, + { base: "Indexed", wait: false, failure: false, expected: "Indexed" }, + { base: "Standby", wait: false, failure: false, expected: "Standby" }, + ] as const)( + "$base with wait=$wait failure=$failure is $expected", + async ({ base, wait, failure, expected }) => { + const { manager, stateManager } = makeManager() + stateManager.setSystemState(base, "base") + if (failure) withFailure(manager) + const release = wait ? await withWait(manager) : async () => {} + try { + const status = manager.getCurrentStatus() + expect(status.systemStatus).toBe(expected) + expect(status.failedFiles).toHaveLength(failure ? 1 : 0) + expect(status.providerWaits).toHaveLength(wait ? 1 : 0) + } finally { + await release() + } + }, + ) + }) + + it("CI-MG-04: a CacheReadError from initialize() ends in Error and keeps existing data", async () => { + const { manager, stateManager } = makeManager() + manager["_cacheManager"] = undefined + const clear = vi.spyOn(CacheManager.prototype, "clearCacheFile") + vi.spyOn(CacheManager.prototype, "initialize").mockRejectedValue(new CacheReadError(new Error("EACCES"))) + const recreate = vi.spyOn(manager as unknown as { _recreateServices(): Promise }, "_recreateServices") + manager["_configManager"] = { + isFeatureEnabled: true, + isFeatureConfigured: true, + loadConfiguration: async () => ({ requiresRestart: false }), + } as unknown as (typeof manager)["_configManager"] + + // The double stands in for ContextProxy; initialize() only passes it to an existing config manager. + await manager.initialize({} as unknown as Parameters[0]) + + expect(stateManager.state).toBe("Error") + expect(recreate).not.toHaveBeenCalled() + expect(clear).not.toHaveBeenCalled() + expect(manager["_cacheManager"]).toBeUndefined() + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 1263aaef14..ac10d087ac 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -3,6 +3,7 @@ import { makeExtensionContext } from "../../../test-utils/vscode" import { CodeIndexManager } from "../manager" import { CodeIndexManagerRegistry } from "../code-index-manager-registry" import { CodeIndexServiceFactory } from "../service-factory" +import { CacheManager } from "../cache-manager" import { CodeIndexOrchestrator } from "../orchestrator" import { CodeIndexSearchService } from "../search-service" import type { MockedClass } from "vitest" @@ -241,13 +242,18 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { value: { startIndexing, stopIndexing: vi.fn() }, configurable: true, }) + // The background start re-checks enablement, which a bare manager reports as off. + Object.defineProperty(manager, "isFeatureEnabled", { get: () => true, configurable: true }) + Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => true, configurable: true }) manager["startIndexingInBackground"]() - await Promise.resolve() - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[CodeIndexManager] Background indexing failed:", - indexingError, + // The start-promise join (R13) adds microtask hops before the catch runs. + await vi.waitFor(() => + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[CodeIndexManager] Background indexing failed:", + indexingError, + ), ) consoleErrorSpy.mockRestore() }) @@ -353,7 +359,10 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { ;(manager as any)._cacheManager = mockCacheManager // Simulate an initialized manager by setting the required properties - ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } + ;(manager as any)._orchestrator = { + stopWatcher: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn().mockResolvedValue(undefined), + } ;(manager as any)._searchService = {} // Verify manager is considered initialized @@ -547,7 +556,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) // Mock orchestrator and search service to simulate initialized state - ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn(), state: "Error" } + ;(manager as any)._orchestrator = { + stopWatcher: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn().mockResolvedValue(undefined), + state: "Error", + } ;(manager as any)._searchService = {} ;(manager as any)._serviceFactory = {} }) @@ -633,6 +646,9 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }), } + // The vscode mock has no fs; a missing cache is the only empty-cache case under D8. + vi.spyOn(CacheManager.prototype, "initialize").mockResolvedValue(undefined) + // Enable workspace indexing before re-initialization await manager.setWorkspaceEnabled(true) @@ -679,7 +695,10 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { // Setup manager with service instances ;(manager as any)._configManager = mockConfigManager ;(manager as any)._serviceFactory = {} - ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } + ;(manager as any)._orchestrator = { + stopWatcher: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn().mockResolvedValue(undefined), + } ;(manager as any)._searchService = {} // Spy on console.error @@ -810,15 +829,15 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) describe("stopIndexing", () => { - it("should delegate to orchestrator.stopIndexing()", () => { + it("should delegate to orchestrator.stopIndexing()", async () => { const mockOrchestrator = { - stopIndexing: vi.fn(), + stopIndexing: vi.fn().mockResolvedValue(undefined), stopWatcher: vi.fn(), state: "Indexing", } ;(manager as any)._orchestrator = mockOrchestrator - manager.stopIndexing() + await manager.stopIndexing() expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() }) diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index 1a81c1abfa..cfadaef0f8 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -1,626 +1,405 @@ -import { describe, it, expect, beforeEach, vi } from "vitest" import { CodeIndexOrchestrator } from "../orchestrator" +import { FileFailureRegistry } from "../file-failure-registry" +import { ProviderPacerRegistry } from "../provider-pacer" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import type { BatchProcessingSummary, IDirectoryScanner } from "../interfaces" import { clearAllMocks } from "../../../test-utils/reset" -// Mock vscode workspace so startIndexing passes workspace check -vi.mock("vscode", () => { - const path = require("path") - const testWorkspacePath = path.join(path.sep, "test", "workspace") - return { - window: { - activeTextEditor: null, - }, - workspace: { - workspaceFolders: [ - { - uri: { fsPath: testWorkspacePath }, - name: "test", - index: 0, - }, - ], - createFileSystemWatcher: vi.fn().mockReturnValue({ - onDidCreate: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }), - dispose: vi.fn(), - }), - }, - RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { - return { base, pattern } - }), - } -}) +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: [{ uri: { fsPath: "/test/workspace" }, name: "test", index: 0 }] }, +})) -// Mock TelemetryService vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureEvent: vi.fn(), - }, - }, + TelemetryService: { instance: { captureEvent: vi.fn() } }, })) -// Mock i18n translator used in orchestrator messages vi.mock("../../../i18n", () => ({ - t: (key: string, params?: { errorMessage?: string }) => { - if (key === "embeddings:orchestrator.failedDuringInitialScan" && params?.errorMessage) { - return `Failed during initial scan: ${params.errorMessage}` - } - return key - }, + t: (key: string, params?: { errorMessage?: string }) => + params?.errorMessage ? `${key}: ${params.errorMessage}` : key, })) -describe("CodeIndexOrchestrator - error path cleanup gating", () => { - const workspacePath = "/test/workspace" - - let configManager: any - let stateManager: any - let cacheManager: any - let vectorStore: any - let scanner: any - let fileWatcher: any - - beforeEach(() => { - clearAllMocks() - - configManager = { - isFeatureConfigured: true, - } - - // Minimal state manager that tracks state transitions - let currentState = "Standby" - stateManager = { - get state() { - return currentState - }, - setSystemState: vi.fn().mockImplementation((state: string, _msg: string) => { - currentState = state - }), - reportFileQueueProgress: vi.fn(), - reportBlockIndexingProgress: vi.fn(), - } - - cacheManager = { - clearCacheFile: vi.fn().mockResolvedValue(undefined), - flush: vi.fn().mockResolvedValue(undefined), - } - - vectorStore = { - initialize: vi.fn(), - hasIndexedData: vi.fn(), - markIndexingIncomplete: vi.fn(), - markIndexingComplete: vi.fn(), - clearCollection: vi.fn().mockResolvedValue(undefined), - } - - scanner = { - scanDirectory: vi.fn(), - } - - fileWatcher = { - initialize: vi.fn().mockResolvedValue(undefined), - onDidStartBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onBatchProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onDidFinishBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), - dispose: vi.fn(), - } - }) - - it.each([false, true])( - "keeps watcher startup and completion after scanning (incremental: %s)", - async (incremental) => { - const events: string[] = [] - vectorStore.initialize.mockResolvedValue(false) - vectorStore.hasIndexedData.mockResolvedValue(incremental) - vectorStore.markIndexingIncomplete.mockImplementation(async () => { - events.push("incomplete") - }) - scanner.scanDirectory.mockImplementation(async () => { - events.push("scan") - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - }) - fileWatcher.initialize.mockImplementation(async () => { - events.push("watcher") - }) - vectorStore.markIndexingComplete.mockImplementation(async () => { - events.push("complete") - }) - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - await orchestrator.startIndexing() - expect(events).toEqual(["incomplete", "scan", "watcher", "complete"]) - expect(orchestrator.state).toBe("Indexed") - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - }, - ) +type ScanResult = Awaited> +const emptyScan: ScanResult = { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - it.each([false, true])( - "keeps returned cancellation cleanup in the owner (incremental: %s)", - async (incremental) => { - vectorStore.initialize.mockResolvedValue(false) - vectorStore.hasIndexedData.mockResolvedValue(incremental) - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - scanner.scanDirectory.mockImplementation(async () => { - orchestrator.stopIndexing() - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - }) - // Preserve the original normal-return path: a failed flush is retried by the catch handler. - cacheManager.flush.mockRejectedValueOnce(new Error("flush failed")) - await orchestrator.startIndexing() - expect(cacheManager.flush).toHaveBeenCalledTimes(2) - expect(orchestrator.state).toBe("Standby") - expect(fileWatcher.initialize).not.toHaveBeenCalled() - expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled() - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() +function setup() { + let currentState = "Standby" + const stateManager = { + get state() { + return currentState }, + setSystemState: vi.fn((state: string, _message?: string) => { + currentState = state + }), + reportFileQueueProgress: vi.fn(), + reportBlockIndexingProgress: vi.fn(), + } + const cacheManager = { + clearCacheFile: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), + } + const vectorStore = { + initialize: vi.fn().mockResolvedValue(false), + hasIndexedData: vi.fn().mockResolvedValue(false), + markIndexingIncomplete: vi.fn().mockResolvedValue(undefined), + markIndexingComplete: vi.fn().mockResolvedValue(undefined), + clearCollection: vi.fn().mockResolvedValue(undefined), + deleteCollection: vi.fn().mockResolvedValue(undefined), + } + const scanner = { scanDirectory: vi.fn().mockResolvedValue(emptyScan) } + const fileWatcher = { + initialize: vi.fn().mockResolvedValue(undefined), + onDidStartBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onBatchProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidFinishBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + dispose: vi.fn(), + } + const failures = new FileFailureRegistry() + const pacers = new ProviderPacerRegistry() + // The doubles provide only the members the orchestrator calls on each collaborator. + type Arguments = ConstructorParameters + const orchestrator = new CodeIndexOrchestrator( + { isFeatureConfigured: true } as unknown as Arguments[0], + stateManager as unknown as Arguments[1], + "/test/workspace", + cacheManager as unknown as Arguments[3], + vectorStore as unknown as Arguments[4], + scanner as unknown as Arguments[5], + fileWatcher as unknown as Arguments[6], + failures, + pacers, ) - - it("should not call clearCollection() or clear cache when initialize() fails (indexing not started)", async () => { - // Arrange: fail at initialize() - vectorStore.initialize.mockRejectedValue(new Error("Qdrant unreachable")) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - // Act - await orchestrator.startIndexing() - - // Assert - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() - - // Error state should be set - expect(stateManager.setSystemState).toHaveBeenCalled() - const lastCall = stateManager.setSystemState.mock.calls[stateManager.setSystemState.mock.calls.length - 1] - expect(lastCall[0]).toBe("Error") + return { orchestrator, stateManager, cacheManager, vectorStore, scanner, fileWatcher, failures, pacers } +} + +/** A scanner that waits until its signal aborts, then returns normally. */ +function waitForAbort(...args: Parameters) { + const signal = args[4] + return new Promise((resolve) => { + if (signal?.aborted) return resolve(emptyScan) + signal?.addEventListener("abort", () => resolve(emptyScan), { once: true }) }) +} - it.each([false, true])( - "only cleans up a failed full scan when the collection was created by this run: %s", - async (created) => { - vectorStore.initialize.mockResolvedValue(created) - vectorStore.hasIndexedData.mockResolvedValue(false) // force full scan path - vectorStore.markIndexingIncomplete.mockRejectedValue(new Error("mark incomplete failure")) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - // Act - await orchestrator.startIndexing() +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) - expect(vectorStore.clearCollection).toHaveBeenCalledTimes(created ? 1 : 0) - // A new collection clears stale cache at initialization and again on failure. - expect(cacheManager.clearCacheFile).toHaveBeenCalledTimes(created ? 2 : 0) +type Progress = { processedInBatch: number; totalInBatch: number; currentFile?: string } - // Error state should be set - expect(stateManager.setSystemState).toHaveBeenCalled() - const lastCall = stateManager.setSystemState.mock.calls[stateManager.setSystemState.mock.calls.length - 1] - expect(lastCall[0]).toBe("Error") - }, - ) - - it.each([false, true])( - "only cleans up after partial full-scan progress when the collection was created by this run: %s", - async (created) => { - const failure = new Error("batch failure after partial progress") - vectorStore.initialize.mockResolvedValue(created) - vectorStore.hasIndexedData.mockResolvedValue(false) - vectorStore.markIndexingIncomplete.mockResolvedValue(undefined) - scanner.scanDirectory.mockImplementation( - async ( - _dir: string, - onError: (error: Error) => void, - onIndexed: (count: number) => void, - onParsed: (count: number) => void, - ) => { - onParsed(3) - onIndexed(1) - onError(failure) - return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 } - }, - ) - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - await orchestrator.startIndexing() - - expect(scanner.scanDirectory).toHaveBeenCalledOnce() - expect(stateManager.reportBlockIndexingProgress).toHaveBeenLastCalledWith(1, 3) - expect(stateManager.setSystemState).toHaveBeenLastCalledWith( - "Error", - expect.stringContaining(failure.message), - ) - expect(stateManager.setSystemState).not.toHaveBeenCalledWith("Indexed", expect.any(String)) - expect(vectorStore.clearCollection).toHaveBeenCalledTimes(created ? 1 : 0) - // New collections clear stale cache before scanning and again during error cleanup. - expect(cacheManager.clearCacheFile).toHaveBeenCalledTimes(created ? 2 : 0) - expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledOnce() - expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled() - expect(fileWatcher.initialize).not.toHaveBeenCalled() - expect(fileWatcher.dispose).toHaveBeenCalledOnce() +function liveListeners() { + const listeners = new Set<(value: T) => void>() + return { + listeners, + subscribe: (listener: (value: T) => void) => { + listeners.add(listener) + return { dispose: () => void listeners.delete(listener) } }, - ) - - it.each([false, true])( - "keeps private error details out of telemetry (cleanup failure: %s)", - async (cleanupFails) => { - const messages = [ - "Embedding failed (Workspace: /Users/private-user/Secret Project, File: /Users/private-user/Secret Project/src/private.ts)", - "Embedding failed (Workspace: C:\\Users\\private-user\\Secret Project, File: C:\\Users\\private-user\\Secret Project\\private.ts)", - ] - const failures = messages.map((message) => { - const error = new Error(message) - error.stack = `${message}\n at privateFunction (/Users/private-user/Secret Project/private.ts:12:3)` - return error - }) - vectorStore.initialize.mockResolvedValue(cleanupFails) - vectorStore.hasIndexedData.mockResolvedValue(!cleanupFails) - vectorStore.markIndexingIncomplete.mockResolvedValue(undefined) - scanner.scanDirectory.mockImplementation(async (_dir: string, onError: (error: Error) => void) => { - for (const failure of failures) onError(failure) - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - }) - if (cleanupFails) vectorStore.clearCollection.mockRejectedValue(failures[1]) - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - await orchestrator.startIndexing() + fire: (value: T) => listeners.forEach((listener) => listener(value)), + } +} + +/** Wires the watcher double's three events to emitters whose subscriptions really dispose. */ +function emitters(fileWatcher: ReturnType["fileWatcher"]) { + const start = liveListeners() + const progress = liveListeners() + const finish = liveListeners() + fileWatcher.onDidStartBatchProcessing.mockImplementation(start.subscribe) + fileWatcher.onBatchProgressUpdate.mockImplementation(progress.subscribe) + fileWatcher.onDidFinishBatchProcessing.mockImplementation(finish.subscribe) + const events = { + start: () => start.fire(), + progress: progress.fire, + finish: () => finish.fire({ processedFiles: [] }), + listenerCounts: () => [start.listeners.size, progress.listeners.size, finish.listeners.size], + } + /** Same order as `FileWatcher.processBatch()`: start, progress, finish, then two trailing progress events. */ + const runBatch = (total: number, duringBatch?: () => void) => { + events.start() + events.progress({ processedInBatch: 0, totalInBatch: total }) + duringBatch?.() + events.progress({ processedInBatch: total, totalInBatch: total, currentFile: "/w/a.ts" }) + events.finish() + events.progress({ processedInBatch: total, totalInBatch: total }) + events.progress({ processedInBatch: 0, totalInBatch: 0 }) + } + return { runBatch, events } +} + +describe("CodeIndexOrchestrator", () => { + beforeEach(() => clearAllMocks()) + + it.each([false, true])("starts the watcher and completes after a scan (incremental: %s)", async (incremental) => { + const context = setup() + const events: string[] = [] + context.vectorStore.hasIndexedData.mockResolvedValue(incremental) + context.vectorStore.markIndexingIncomplete.mockImplementation(async () => void events.push("incomplete")) + context.scanner.scanDirectory.mockImplementation(async () => { + events.push("scan") + return emptyScan + }) + context.fileWatcher.initialize.mockImplementation(async () => void events.push("watcher")) + context.vectorStore.markIndexingComplete.mockImplementation(async () => void events.push("complete")) - const expectedEvents = [ - [TelemetryEventName.CODE_INDEX_ERROR, { error: "Indexing failed", location: "startIndexing" }], - ] - if (cleanupFails) { - expectedEvents.push([ - TelemetryEventName.CODE_INDEX_ERROR, - { error: "Index cleanup failed", location: "startIndexing.cleanup" }, - ]) - } - // Exact payload assertions also exclude stacks, nested causes and aggregate error objects. - expect(vi.mocked(TelemetryService.instance.captureEvent).mock.calls).toEqual(expectedEvents) - expect(stateManager.setSystemState).toHaveBeenLastCalledWith("Error", expect.stringContaining(messages[0])) - if (!cleanupFails) { - expect(stateManager.setSystemState).toHaveBeenLastCalledWith( - "Error", - expect.stringContaining(messages[1]), - ) - } - }, - ) + await context.orchestrator.startIndexing() - it("preserves an existing index after an incremental failure and a failed full-scan retry", async () => { - let complete = true - vectorStore.initialize.mockResolvedValue(false) - vectorStore.hasIndexedData.mockImplementation(async () => complete) - vectorStore.markIndexingIncomplete.mockImplementation(async () => { - complete = false - }) - scanner.scanDirectory.mockImplementation(async (_dir: string, onError: (error: Error) => void) => { - onError(new Error("embedding failed")) - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - }) - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - for (let attempt = 1; attempt <= 2; attempt++) { - await orchestrator.startIndexing() - expect(complete).toBe(false) - expect(orchestrator.state).toBe("Error") - expect(scanner.scanDirectory).toHaveBeenCalledTimes(attempt) - expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledTimes(attempt) - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() - expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled() - expect(fileWatcher.initialize).not.toHaveBeenCalled() - } - expect(stateManager.setSystemState).toHaveBeenCalledWith("Indexing", "Checking for new or modified files...") - expect(stateManager.setSystemState).toHaveBeenCalledWith( - "Indexing", - "Services ready. Starting workspace scan...", - ) - expect(stateManager.setSystemState).not.toHaveBeenCalledWith("Indexed", expect.any(String)) + expect(events).toEqual(["incomplete", "scan", "watcher", "complete"]) + expect(context.orchestrator.state).toBe("Indexed") + expect(context.fileWatcher.initialize).toHaveBeenCalledWith(expect.any(AbortSignal)) }) - it("collects batch errors from full scan and transitions to Error when all blocks fail", async () => { - const batchError = new Error("batch failure") - vectorStore.initialize.mockResolvedValue(false) // existing collection - vectorStore.hasIndexedData.mockResolvedValue(false) // force full scan path - vectorStore.markIndexingIncomplete.mockResolvedValue(undefined) - vectorStore.markIndexingComplete.mockResolvedValue(undefined) - - // Report a batch error — no blocks indexed, so orchestrator treats it as complete failure - scanner.scanDirectory.mockImplementation(async (_dir: string, onBatchError: (e: Error) => void) => { - onBatchError(batchError) - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + it("completes a scan with file failures as idle partial (Standby), without marking it complete", async () => { + const context = setup() + context.scanner.scanDirectory.mockImplementation(async (_dir, onError) => { + context.failures.renew("a.ts", "h") + context.failures.record("a.ts", "h", "embed", "server down", 3, "exhausted") + onError?.(new Error("server down")) + return emptyScan }) + await context.orchestrator.startIndexing() + expect(context.orchestrator.state).toBe("Standby") + expect(context.vectorStore.markIndexingComplete).not.toHaveBeenCalled() + expect(context.fileWatcher.initialize).toHaveBeenCalled() - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - await orchestrator.startIndexing() - - // With a batch error and zero indexed blocks the orchestrator sets Error state - const calls = stateManager.setSystemState.mock.calls.map((c: any[]) => c[0]) - expect(calls[calls.length - 1]).toBe("Error") + context.failures.clear("a.ts", "h") + expect(context.orchestrator.state).toBe("Indexed") }) - it.each([0, 2])( - "preserves the existing index on incremental batch failure after %s indexed blocks", - async (indexed) => { - const batchError = new Error("incremental batch failure") - vectorStore.initialize.mockResolvedValue(false) // existing collection - vectorStore.hasIndexedData.mockResolvedValue(true) // force incremental scan path - vectorStore.markIndexingIncomplete.mockResolvedValue(undefined) - vectorStore.markIndexingComplete.mockResolvedValue(undefined) - - scanner.scanDirectory.mockImplementation( - async (_dir: string, onBatchError: (e: Error) => void, onIndexed: (count: number) => void) => { - onIndexed(indexed) - onBatchError(batchError) - return { stats: { processed: 1, skipped: 0 }, totalBlockCount: 3 } - }, - ) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) + describe("watcher batch final state", () => { + function progressSetup() { + const context = setup() + context.stateManager.reportFileQueueProgress.mockImplementation(() => { + context.stateManager.setSystemState("Indexing") + }) + return { context, ...emitters(context.fileWatcher) } + } - await orchestrator.startIndexing() + it("ends in Indexed after batch-finished and the two trailing progress events", async () => { + const { context, runBatch } = progressSetup() + await context.orchestrator.startIndexing() + expect(context.orchestrator.state).toBe("Indexed") + runBatch(2) + expect(context.orchestrator.state).toBe("Indexed") + }) - expect(orchestrator.state).toBe("Error") - expect(stateManager.setSystemState).toHaveBeenLastCalledWith( - "Error", - expect.stringContaining(batchError.message), + it("ends in partial Standby when the batch recorded a failure", async () => { + const { context, runBatch } = progressSetup() + await context.orchestrator.startIndexing() + runBatch(1, () => { + context.failures.renew("a.ts", "h") + context.failures.record("a.ts", "h", "embed", "down", 3, "exhausted") + }) + expect(context.orchestrator.state).toBe("Standby") + expect(context.stateManager.setSystemState).toHaveBeenLastCalledWith( + "Standby", + "embeddings:orchestrator.indexingFilesFailed", ) - expect(stateManager.setSystemState).not.toHaveBeenCalledWith("Indexed", expect.any(String)) - expect(vectorStore.markIndexingIncomplete).toHaveBeenCalledOnce() - expect(vectorStore.markIndexingComplete).not.toHaveBeenCalled() - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() - expect(fileWatcher.initialize).not.toHaveBeenCalled() - expect(fileWatcher.dispose).toHaveBeenCalledOnce() - }, - ) -}) + }) -describe("CodeIndexOrchestrator - stopIndexing", () => { - const workspacePath = "/test/workspace" + it("subscribes new watcher listeners after stop and restart", async () => { + const { context, runBatch, events } = progressSetup() + await context.orchestrator.startIndexing() + await context.orchestrator.stopIndexing() + expect(events.listenerCounts()).toEqual([0, 0, 0]) + + await context.orchestrator.startIndexing() + expect(events.listenerCounts()).toEqual([1, 1, 1]) + expect(context.fileWatcher.onDidStartBatchProcessing).toHaveBeenCalledTimes(2) + runBatch(1, () => expect(context.orchestrator.state).toBe("Indexing")) + expect(context.orchestrator.state).toBe("Indexed") + expect(context.fileWatcher.initialize).toHaveBeenCalledTimes(2) + }) - let configManager: any - let stateManager: any - let cacheManager: any - let vectorStore: any - let scanner: any - let fileWatcher: any + it("stays Indexing while a second queued batch is pending after the first finishes", async () => { + const { context, events } = progressSetup() + await context.orchestrator.startIndexing() + events.start() + events.start() + events.progress({ processedInBatch: 0, totalInBatch: 1 }) + events.finish() + events.progress({ processedInBatch: 1, totalInBatch: 1 }) + + context.failures.renew("a.ts", "h") + context.failures.record("a.ts", "h", "embed", "down", 1, "retrying") + context.failures.clear("a.ts", "h") + expect(context.orchestrator.state).toBe("Indexing") + + events.progress({ processedInBatch: 0, totalInBatch: 1 }) + events.finish() + expect(context.orchestrator.state).toBe("Indexed") + }) + }) - beforeEach(() => { - clearAllMocks() + it("does not clean up when initialize() fails and ends in Error", async () => { + const context = setup() + context.vectorStore.initialize.mockRejectedValue(new Error("Qdrant unreachable")) + await context.orchestrator.startIndexing() + expect(context.vectorStore.clearCollection).not.toHaveBeenCalled() + expect(context.cacheManager.clearCacheFile).not.toHaveBeenCalled() + expect(context.orchestrator.state).toBe("Error") + }) - configManager = { - isFeatureConfigured: true, - } + it.each([false, true])("only cleans up a failed scan when this run created the collection: %s", async (created) => { + const context = setup() + context.vectorStore.initialize.mockResolvedValue(created) + context.vectorStore.markIndexingIncomplete.mockRejectedValue(new Error("mark incomplete failure")) + await context.orchestrator.startIndexing() + expect(context.vectorStore.clearCollection).toHaveBeenCalledTimes(created ? 1 : 0) + expect(context.cacheManager.clearCacheFile).toHaveBeenCalledTimes(created ? 2 : 0) + expect(context.orchestrator.state).toBe("Error") + expect(context.fileWatcher.dispose).toHaveBeenCalled() + }) - let currentState = "Standby" - stateManager = { - get state() { - return currentState - }, - setSystemState: vi.fn().mockImplementation((state: string, _msg: string) => { - currentState = state - }), - reportFileQueueProgress: vi.fn(), - reportBlockIndexingProgress: vi.fn(), - } + it("keeps private error details out of telemetry", async () => { + const context = setup() + const message = "Failed (Workspace: /Users/private-user/Secret Project)" + context.scanner.scanDirectory.mockRejectedValue(new Error(message)) + await context.orchestrator.startIndexing() + expect(vi.mocked(TelemetryService.instance.captureEvent).mock.calls).toEqual([ + [TelemetryEventName.CODE_INDEX_ERROR, { error: "Indexing failed", location: "startIndexing" }], + ]) + expect(context.stateManager.setSystemState).toHaveBeenLastCalledWith("Error", expect.stringContaining(message)) + }) - cacheManager = { - clearCacheFile: vi.fn().mockResolvedValue(undefined), - flush: vi.fn().mockResolvedValue(undefined), - } + describe("stop", () => { + it("CI-OR-01: repeated stops join one promise and end in Standby without clearing data", async () => { + const context = setup() + context.scanner.scanDirectory.mockImplementation(waitForAbort) + const start = context.orchestrator.startIndexing() + await tick() + + const first = context.orchestrator.stopIndexing() + const second = context.orchestrator.stopIndexing() + expect(second).toBe(first) + await Promise.all([first, start]) + + expect(context.orchestrator.state).toBe("Standby") + expect(context.stateManager.setSystemState).toHaveBeenLastCalledWith( + "Standby", + "embeddings:orchestrator.indexingStoppedPartial", + ) + expect(context.cacheManager.flush).toHaveBeenCalledOnce() + expect(context.vectorStore.clearCollection).not.toHaveBeenCalled() + expect(context.cacheManager.clearCacheFile).not.toHaveBeenCalled() + expect(context.fileWatcher.dispose).toHaveBeenCalled() + }) - vectorStore = { - initialize: vi.fn().mockResolvedValue(false), - hasIndexedData: vi.fn().mockResolvedValue(false), - markIndexingIncomplete: vi.fn().mockResolvedValue(undefined), - markIndexingComplete: vi.fn().mockResolvedValue(undefined), - clearCollection: vi.fn().mockResolvedValue(undefined), - } + it("CI-OR-01: a stop during a provider wait reaches Standby without waiting for the delay", async () => { + vi.useFakeTimers() + try { + const context = setup() + const limited = Object.assign(new Error("limited"), { status: 429, retryAfterMs: 3_600_000 }) + context.scanner.scanDirectory.mockImplementation( + async (_dir, _onError, _onIndexed, _onParsed, signal) => { + const pacer = context.pacers.get("openai:1") + await pacer.run("a.ts", signal, () => Promise.reject(limited)).catch(() => {}) + return emptyScan + }, + ) + const start = context.orchestrator.startIndexing() + await vi.advanceTimersByTimeAsync(0) + expect(context.pacers.waitingPaths()).toEqual(["a.ts"]) + expect(context.orchestrator.state).toBe("Indexing") + + await context.orchestrator.stopIndexing() + await start + expect(context.orchestrator.state).toBe("Standby") + expect(context.pacers.waitingPaths()).toEqual([]) + expect(context.failures.list()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) - scanner = { - scanDirectory: vi.fn(), - } + it("CI-OR-02: no status write after the abort except the final Standby", async () => { + const context = setup() + let report: ((count: number) => void) | undefined + context.scanner.scanDirectory.mockImplementation(async (dir, onError, onIndexed, onParsed, signal) => { + report = onParsed + return waitForAbort(dir, onError, onIndexed, onParsed, signal) + }) + const start = context.orchestrator.startIndexing() + await tick() + + const stop = context.orchestrator.stopIndexing() + const writesAtAbort = context.stateManager.setSystemState.mock.calls.length + report?.(5) + context.failures.renew("late.ts", "h") + context.failures.record("late.ts", "h", "embed", "late", 1, "exhausted") + await Promise.all([stop, start]) + + const lateWrites = context.stateManager.setSystemState.mock.calls + .slice(writesAtAbort) + .map(([state]) => state) + expect(lateWrites).toEqual(["Standby"]) + expect(context.stateManager.reportBlockIndexingProgress).not.toHaveBeenCalled() + }) - fileWatcher = { - initialize: vi.fn().mockResolvedValue(undefined), - onDidStartBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onBatchProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), - onDidFinishBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), - dispose: vi.fn(), - } - }) + it("CI-OR-03: stop then start on the same orchestrator runs a new session and writes status again", async () => { + const context = setup() + context.scanner.scanDirectory.mockImplementationOnce(waitForAbort) + const firstStart = context.orchestrator.startIndexing() + await tick() + await context.orchestrator.stopIndexing() + await firstStart + expect(context.orchestrator.state).toBe("Standby") + + await context.orchestrator.startIndexing() + expect(context.scanner.scanDirectory).toHaveBeenCalledTimes(2) + expect(context.orchestrator.state).toBe("Indexed") + }) - it("should abort indexing when stopIndexing() is called", async () => { - // Make scanner hang until aborted - scanner.scanDirectory.mockImplementation( - async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { - // Wait for abort signal - await new Promise((resolve) => { - if (signal?.aborted) { - resolve() - return - } - signal?.addEventListener("abort", () => resolve()) + it("a start after a partial index stops the watcher session and rescans the failed file", async () => { + const context = setup() + context.scanner.scanDirectory + .mockImplementationOnce(async () => { + context.failures.renew("a.ts", "h") + context.failures.record("a.ts", "h", "embed", "server down", 3, "exhausted") + return emptyScan }) - return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } - }, - ) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - // Start indexing (async, don't await) - const indexingPromise = orchestrator.startIndexing() - - // Give it a tick to begin - await new Promise((resolve) => setTimeout(resolve, 10)) - - // Stop indexing - orchestrator.stopIndexing() - - // Wait for indexing to complete - await indexingPromise - - // State should be Standby (not Error) - const setStateCalls = stateManager.setSystemState.mock.calls - const lastCall = setStateCalls[setStateCalls.length - 1] - expect(lastCall[0]).toBe("Standby") - }) - - it("should set state to Standby after abort, not Error", async () => { - // Make scanner throw AbortError when signal is aborted - scanner.scanDirectory.mockImplementation( - async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { - await new Promise((resolve) => { - if (signal?.aborted) { - resolve() - return - } - signal?.addEventListener("abort", () => resolve()) + .mockImplementationOnce(async () => { + context.failures.clear("a.ts", "h") + return emptyScan }) - throw new DOMException("Indexing aborted", "AbortError") - }, - ) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - const indexingPromise = orchestrator.startIndexing() - await new Promise((resolve) => setTimeout(resolve, 10)) - - orchestrator.stopIndexing() - await indexingPromise - - // Should NOT have set Error state — abort is handled gracefully - const errorCalls = stateManager.setSystemState.mock.calls.filter((call: any[]) => call[0] === "Error") - expect(errorCalls).toHaveLength(0) - - // Should NOT have cleared collection on abort - expect(vectorStore.clearCollection).not.toHaveBeenCalled() - }) + await context.orchestrator.startIndexing() + expect(context.orchestrator.state).toBe("Standby") + + await context.orchestrator.startIndexing() + expect(context.scanner.scanDirectory).toHaveBeenCalledTimes(2) + expect(context.fileWatcher.dispose).toHaveBeenCalled() + expect(context.failures.list()).toEqual([]) + expect(context.orchestrator.state).toBe("Indexed") + }) - it("should preserve partial index data after stop", async () => { - scanner.scanDirectory.mockImplementation( - async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { - await new Promise((resolve) => { - if (signal?.aborted) { - resolve() - return - } - signal?.addEventListener("abort", () => resolve()) - }) - return { stats: { processed: 5, skipped: 0 }, totalBlockCount: 5 } - }, - ) - - const orchestrator = new CodeIndexOrchestrator( - configManager, - stateManager, - workspacePath, - cacheManager, - vectorStore, - scanner, - fileWatcher, - ) - - const indexingPromise = orchestrator.startIndexing() - await new Promise((resolve) => setTimeout(resolve, 10)) - - orchestrator.stopIndexing() - await indexingPromise - - // Cache should NOT be cleared on user-initiated stop - expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() - // Collection should NOT be cleared on user-initiated stop - expect(vectorStore.clearCollection).not.toHaveBeenCalled() + it("a start while stopping waits for the stop, then starts", async () => { + const context = setup() + context.scanner.scanDirectory.mockImplementationOnce(waitForAbort) + const firstStart = context.orchestrator.startIndexing() + await tick() + const stop = context.orchestrator.stopIndexing() + const secondStart = context.orchestrator.startIndexing() + await Promise.all([stop, firstStart, secondStart]) + expect(context.scanner.scanDirectory).toHaveBeenCalledTimes(2) + expect(context.orchestrator.state).toBe("Indexed") + }) + + it("a repeated start while scanning returns the running scan", async () => { + const context = setup() + context.scanner.scanDirectory.mockImplementationOnce(waitForAbort) + const first = context.orchestrator.startIndexing() + await tick() + const second = context.orchestrator.startIndexing() + await tick() + expect(context.scanner.scanDirectory).toHaveBeenCalledOnce() + await context.orchestrator.stopIndexing() + await Promise.all([first, second]) + }) + + it("preserves Error across a stop", async () => { + const context = setup() + context.vectorStore.initialize.mockRejectedValue(new Error("down")) + await context.orchestrator.startIndexing() + await context.orchestrator.stopIndexing() + expect(context.orchestrator.state).toBe("Error") + }) }) }) diff --git a/src/services/code-index/__tests__/provider-pacer.spec.ts b/src/services/code-index/__tests__/provider-pacer.spec.ts new file mode 100644 index 0000000000..5f455f94b0 --- /dev/null +++ b/src/services/code-index/__tests__/provider-pacer.spec.ts @@ -0,0 +1,187 @@ +import { ProviderPacer, ProviderPacerRegistry, makeRateLimitScope } from "../provider-pacer" + +const rateLimit = (retryAfterMs?: number) => Object.assign(new Error("limited"), { status: 429, retryAfterMs }) + +async function settle(promise: Promise) { + return promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ) +} + +describe("makeRateLimitScope (CI-PP-01)", () => { + it.each([ + ["same configuration", ["openai", "https://api.test/v1", "key"], ["openai", "https://api.test/v1", "key"]], + ["trailing slash", ["openai", "https://api.test/v1/", "key"], ["openai", "https://api.test/v1", "key"]], + ["credentials in URL", ["openai", "https://u:p@api.test/v1", "key"], ["openai", "https://api.test/v1", "key"]], + ["URL hash", ["openai", "https://api.test/v1#x", "key"], ["openai", "https://api.test/v1", "key"]], + ])("gives equal scopes for %s", (_label, first, second) => { + const [provider, url, key] = first + const [provider2, url2, key2] = second + expect(makeRateLimitScope(provider, url, key)).toBe(makeRateLimitScope(provider2, url2, key2)) + }) + + it.each([ + ["API key", ["openai", "https://api.test", "a"], ["openai", "https://api.test", "b"]], + ["endpoint", ["openai", "https://a.test", "k"], ["openai", "https://b.test", "k"]], + ["provider", ["openai", "https://a.test", "k"], ["mistral", "https://a.test", "k"]], + ])("gives unequal scopes for a different %s", (_label, first, second) => { + expect(makeRateLimitScope(first[0], first[1], first[2])).not.toBe( + makeRateLimitScope(second[0], second[1], second[2]), + ) + }) + + it("distinguishes extra inputs and never embeds the key", () => { + const scope = makeRateLimitScope("bedrock", undefined, undefined, "us-east-1|default") + expect(scope).not.toBe(makeRateLimitScope("bedrock", undefined, undefined, "us-west-2|default")) + expect(makeRateLimitScope("openai", "https://api.test", "secret-key")).not.toContain("secret-key") + expect(scope.startsWith("bedrock:")).toBe(true) + }) +}) + +describe("ProviderPacer (CI-PP-02)", () => { + beforeEach(() => vi.useFakeTimers({ now: 0 })) + afterEach(() => vi.useRealTimers()) + + it.each([61_000, 120_000])("honors a %d ms Retry-After in full", async (delay) => { + const pacer = new ProviderPacer("openai:1") + const request = vi.fn().mockRejectedValueOnce(rateLimit(delay)).mockResolvedValue("ok") + const run = pacer.run("a.ts", undefined, request) + await vi.advanceTimersByTimeAsync(delay - 1) + expect(request).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await expect(run).resolves.toBe("ok") + expect(request).toHaveBeenCalledTimes(2) + }) + + it("backs off exponentially without a delay, caps at 300 s and resets on success", async () => { + const pacer = new ProviderPacer("openai:1") + const request = vi.fn() + for (let iter = 0; iter < 12; iter++) request.mockRejectedValueOnce(rateLimit()) + request.mockResolvedValue("ok") + const run = pacer.run(undefined, undefined, request) + await vi.advanceTimersByTimeAsync(0) + const waits: number[] = [] + while (request.mock.calls.length < 13) { + const before = Date.now() + const resume = pacer.resumeAt! + await vi.advanceTimersByTimeAsync(resume - before) + waits.push(resume - before) + } + await expect(run).resolves.toBe("ok") + expect(waits.slice(0, 4)).toEqual([1000, 2000, 4000, 8000]) + expect(Math.max(...waits)).toBe(300_000) + + request.mockRejectedValueOnce(rateLimit()).mockResolvedValue("ok") + const next = pacer.run(undefined, undefined, request) + await vi.advanceTimersByTimeAsync(0) + expect(pacer.resumeAt! - Date.now()).toBe(1000) + await vi.advanceTimersByTimeAsync(1000) + await next + }) + + it("never shortens an existing pause when a later 429 asks for less", async () => { + const pacer = new ProviderPacer("openai:1") + let releaseShort: (() => void) | undefined + const shortRequest = vi + .fn() + .mockImplementationOnce( + () => new Promise((_resolve, reject) => (releaseShort = () => reject(rateLimit(1000)))), + ) + .mockResolvedValue("ok") + const short = settle(pacer.run(undefined, undefined, shortRequest)) + const slow = settle(pacer.run(undefined, undefined, vi.fn().mockRejectedValueOnce(rateLimit(60_000)))) + await vi.advanceTimersByTimeAsync(0) + expect(pacer.resumeAt).toBe(60_000) + releaseShort?.() + await vi.advanceTimersByTimeAsync(0) + expect(pacer.resumeAt).toBe(60_000) + await vi.advanceTimersByTimeAsync(59_999) + expect(shortRequest).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await expect(Promise.all([slow, short])).resolves.toEqual([{ value: undefined }, { value: "ok" }]) + expect(shortRequest).toHaveBeenCalledTimes(2) + }) + + it("counts concurrent 429s during one pause as a single backoff step", async () => { + const pacer = new ProviderPacer("openai:1") + const releases: Array<() => void> = [] + const request = () => + vi + .fn() + .mockImplementationOnce( + () => new Promise((_resolve, reject) => releases.push(() => reject(rateLimit()))), + ) + .mockResolvedValue("ok") + const runs = [1, 2, 3, 4].map(() => settle(pacer.run(undefined, undefined, request()))) + await vi.advanceTimersByTimeAsync(0) + for (const release of releases) release() + await vi.advanceTimersByTimeAsync(0) + expect(pacer.resumeAt! - Date.now()).toBe(1000) + await vi.advanceTimersByTimeAsync(1000) + await expect(Promise.all(runs)).resolves.toEqual([1, 2, 3, 4].map(() => ({ value: "ok" }))) + }) + + it("rejects a waiter at once with the abort reason and drops its path", async () => { + const pacer = new ProviderPacer("openai:1") + const first = settle(pacer.run("a.ts", undefined, vi.fn().mockRejectedValueOnce(rateLimit(60_000)))) + await vi.advanceTimersByTimeAsync(0) + const controller = new AbortController() + const request = vi.fn() + const waiting = pacer.run("b.ts", controller.signal, request) + expect(pacer.waitingPaths).toContain("b.ts") + const reason = new Error("stopped") + controller.abort(reason) + await expect(waiting).rejects.toBe(reason) + expect(pacer.waitingPaths).not.toContain("b.ts") + expect(request).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(60_000) + await expect(first).resolves.toEqual({ value: undefined }) + }) + + it("rethrows non-rate-limit errors unchanged", async () => { + const failure = Object.assign(new Error("server"), { status: 500 }) + await expect( + new ProviderPacer("openai:1").run(undefined, undefined, () => Promise.reject(failure)), + ).rejects.toBe(failure) + }) + + it("keeps waits longer than the 32-bit timer limit", async () => { + const pacer = new ProviderPacer("openai:1") + const delay = 2 ** 31 + 10_000 + const request = vi.fn().mockRejectedValueOnce(rateLimit(delay)).mockResolvedValue("ok") + const run = pacer.run(undefined, undefined, request) + await vi.advanceTimersByTimeAsync(2 ** 31) + expect(request).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(10_000) + await expect(run).resolves.toBe("ok") + }) +}) + +describe("ProviderPacerRegistry", () => { + beforeEach(() => vi.useFakeTimers({ now: 0 })) + afterEach(() => vi.useRealTimers()) + + it("shares a scope's pause, isolates other scopes and reports deduplicated waits", async () => { + const registry = new ProviderPacerRegistry() + const changed = vi.fn() + registry.onDidChange(changed) + const paused = settle( + registry.get("openai:1").run("a.ts", undefined, vi.fn().mockRejectedValueOnce(rateLimit(5000))), + ) + await vi.advanceTimersByTimeAsync(0) + const same = settle(registry.get("openai:1").run("a.ts", undefined, vi.fn().mockResolvedValue("ok"))) + const other = vi.fn().mockResolvedValue("other") + await expect(registry.get("qdrant:2").run("b.ts", undefined, other)).resolves.toBe("other") + + expect(registry.providerWaits()).toEqual([{ service: "openai", resumeAt: 5000, waitingFiles: ["a.ts"] }]) + expect(registry.waitingPaths()).toEqual(["a.ts"]) + expect(registry.hasWaitingRequests()).toBe(true) + expect(changed).toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5000) + await expect(Promise.all([paused, same])).resolves.toEqual([{ value: undefined }, { value: "ok" }]) + expect(registry.providerWaits()).toEqual([]) + expect(registry.hasWaitingRequests()).toBe(false) + }) +}) diff --git a/src/services/code-index/__tests__/search-service.spec.ts b/src/services/code-index/__tests__/search-service.spec.ts new file mode 100644 index 0000000000..69543e0c56 --- /dev/null +++ b/src/services/code-index/__tests__/search-service.spec.ts @@ -0,0 +1,158 @@ +import * as path from "path" +import { CodeIndexSearchService } from "../search-service" +import { FileFailureRegistry } from "../file-failure-registry" +import { ProviderPacerRegistry } from "../provider-pacer" +import { SearchProviderRateLimitError } from "../shared/indexing-errors" +import { CodeIndexStateManager, type IndexingState } from "../state-manager" +import type { IEmbedder } from "../interfaces/embedder" +import type { IVectorStore } from "../interfaces/vector-store" + +vi.mock("vscode", () => ({ + EventEmitter: class { + event = vi.fn() + fire(): void {} + dispose(): void {} + }, +})) +vi.mock("../../../i18n", () => ({ t: (key: string) => key })) +vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { captureEvent: vi.fn() } } })) + +function prepare(state: IndexingState, { failed = false, exists = true, enabled = true } = {}) { + const failures = new FileFailureRegistry() + if (failed) { + for (const file of ["src/a.ts", "other/b.ts"]) { + failures.renew(file, "revision") + failures.record(file, "revision", "embed", "unavailable", 3, "exhausted", "server") + } + } + const config = { + isFeatureEnabled: enabled, + isFeatureConfigured: true, + currentSearchMinScore: 0.4, + currentSearchMaxResults: 12, + } + const stateManager = new CodeIndexStateManager() + stateManager.setSystemState(state, "status") + const setSystemState = vi.spyOn(stateManager, "setSystemState") + const embedder = { createEmbeddings: vi.fn() } + embedder.createEmbeddings.mockResolvedValue({ embeddings: [[1, 0]] }) + const store = { + search: vi.fn().mockResolvedValue([]), + collectionExists: vi.fn().mockResolvedValue(exists), + } + const pacers = new ProviderPacerRegistry() + const service = new CodeIndexSearchService(config, stateManager, embedder, store, failures, pacers) + return { service, failures, pacers, store, embedder, setSystemState, stateManager } +} + +async function withWaitingPath(pacers: ProviderPacerRegistry, file: string) { + const controller = new AbortController() + const limited = Object.assign(new Error("limited"), { status: 429, retryAfterMs: 60_000 }) + const pending = pacers + .get("openai:1") + .run(file, controller.signal, () => Promise.reject(limited)) + .catch(() => {}) + await new Promise((resolve) => setTimeout(resolve, 0)) + return async () => { + controller.abort() + await pending + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe("CodeIndexSearchService (CI-SS-01)", () => { + it.each([ + { state: "Indexed", failed: false, incomplete: false }, + { state: "Indexed", failed: true, incomplete: true }, + { state: "Indexing", failed: false, incomplete: true }, + { state: "Standby", failed: true, incomplete: true }, + ] as const)( + "$state with failures=$failed reports incomplete=$incomplete", + async ({ state, failed, incomplete }) => { + const { service } = prepare(state, { failed }) + const response = await service.searchIndex("query") + expect(response.incomplete).toBe(incomplete) + expect(response.failedPaths).toEqual(failed ? ["src/a.ts", "other/b.ts"] : []) + expect(response.waitingPaths).toEqual([]) + }, + ) + + it("reports waiting paths separately and deduplicated, for matches and empty results", async () => { + const { service, pacers, store } = prepare("Indexing", { failed: true }) + const releaseFirst = await withWaitingPath(pacers, "w.ts") + const releaseSecond = await withWaitingPath(pacers, "w.ts") + try { + for (const results of [[], [{ id: "point", score: 0.9 }]]) { + store.search.mockResolvedValueOnce(results) + const response = await service.searchIndex("query") + expect(response.results).toEqual(results) + expect(response).toMatchObject({ incomplete: true, waitingPaths: ["w.ts"] }) + expect(response.failedPaths).toEqual(["src/a.ts", "other/b.ts"]) + } + } finally { + await releaseFirst() + await releaseSecond() + } + }) + + it("reports whole-workspace failures for a directory-scoped search", async () => { + const { service, store } = prepare("Indexed", { failed: true }) + const response = await service.searchIndex("query", "src") + expect(store.search).toHaveBeenCalledWith([1, 0], path.normalize("src"), 0.4, 12) + expect(response.failedPaths).toEqual(["src/a.ts", "other/b.ts"]) + }) + + it.each([ + { state: "Error", failed: true, exists: true }, + { state: "Stopping", failed: true, exists: true }, + { state: "Standby", failed: false, exists: true }, + { state: "Standby", failed: true, exists: false }, + ] as const)( + "rejects search in $state (failures=$failed, collection=$exists)", + async ({ state, failed, exists }) => { + const { service, embedder } = prepare(state, { failed, exists }) + await expect(service.searchIndex("query")).rejects.toThrow(`Current state: ${state}`) + expect(embedder.createEmbeddings).not.toHaveBeenCalled() + }, + ) + + it("rejects when the state moves to Stopping during the collection lookup", async () => { + const { service, store, embedder, stateManager } = prepare("Standby", { failed: true }) + store.collectionExists.mockImplementation(async () => { + stateManager.setSystemState("Stopping", "stopping") + return true + }) + await expect(service.searchIndex("query")).rejects.toThrow("Current state: Stopping") + expect(embedder.createEmbeddings).not.toHaveBeenCalled() + }) + + it("reports incomplete when indexing stops to Standby while the query is in flight", async () => { + const { service, store, stateManager } = prepare("Indexing") + store.search.mockImplementation(async () => { + stateManager.setSystemState("Standby", "stopped") + return [] + }) + const response = await service.searchIndex("query") + expect(response).toMatchObject({ incomplete: true, failedPaths: [], waitingPaths: [] }) + }) + + it("does not set Error when a query fails", async () => { + const { service, embedder, setSystemState } = prepare("Indexed") + embedder.createEmbeddings.mockRejectedValue(Object.assign(new Error("bad"), { status: 400 })) + await expect(service.searchIndex("query")).rejects.toThrow() + expect(setSystemState).not.toHaveBeenCalled() + }) + + it("turns a query rate limit into SearchProviderRateLimitError without waiting", async () => { + const { service, embedder } = prepare("Indexed") + embedder.createEmbeddings.mockRejectedValue(Object.assign(new Error("limited"), { status: 429 })) + await expect(service.searchIndex("query")).rejects.toBeInstanceOf(SearchProviderRateLimitError) + expect(embedder.createEmbeddings).toHaveBeenCalledOnce() + }) + + it("rejects when the feature is disabled", async () => { + const { service } = prepare("Indexed", { enabled: false }) + await expect(service.searchIndex("query")).rejects.toThrow("disabled or not configured") + }) +}) diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index bc1d619af2..e01945cd8c 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -12,6 +12,8 @@ import { VercelAiGatewayEmbedder } from "../embedders/vercel-ai-gateway" import { BedrockEmbedder } from "../embedders/bedrock" import { OpenRouterEmbedder } from "../embedders/openrouter" import type { CodeIndexConfig } from "../interfaces/config" +import { FileFailureRegistry } from "../file-failure-registry" +import { ProviderPacerRegistry } from "../provider-pacer" import { clearAllMocks } from "../../../test-utils/reset" @@ -58,6 +60,8 @@ const mockGetDefaultModelId = getDefaultModelId as MockedFunction describe("CodeIndexServiceFactory", () => { + const failures = new FileFailureRegistry() + const pacers = new ProviderPacerRegistry() let factory: CodeIndexServiceFactory let embedderFactory: EmbedderFactory let vectorStoreFactory: VectorStoreFactory @@ -75,7 +79,7 @@ describe("CodeIndexServiceFactory", () => { mockCacheManager = {} - factory = new CodeIndexServiceFactory(mockConfigManager, "/test/workspace", mockCacheManager) + factory = new CodeIndexServiceFactory(mockConfigManager, "/test/workspace", mockCacheManager, failures, pacers) }) describe("createEmbedder", () => { @@ -91,8 +95,8 @@ describe("CodeIndexServiceFactory", () => { modelId: "second-model", }) - const first = embedderFactory.create(mockConfigManager.getConfig()) - const second = embedderFactory.create(mockConfigManager.getConfig()) + const first = embedderFactory.create(mockConfigManager.getConfig(), pacers) + const second = embedderFactory.create(mockConfigManager.getConfig(), pacers) expect(first).not.toBe(second) expect(MockedOpenAiEmbedder).toHaveBeenNthCalledWith(1, { @@ -118,7 +122,9 @@ describe("CodeIndexServiceFactory", () => { it.each(requiredSettings)("rejects missing options for %s (%s.%s)", (provider, _options, _setting, error) => { mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( + `serviceFactory.${error}`, + ) }) it.each(requiredSettings)("rejects empty %s setting %s.%s", (provider, options, setting, error) => { @@ -127,13 +133,15 @@ describe("CodeIndexServiceFactory", () => { [options]: { baseUrl: "https://example.com", apiKey: "test-key", [setting]: "" }, }) - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( + `serviceFactory.${error}`, + ) }) it.each(["constructor", "toString", "__proto__"])("rejects prototype key %s as a provider", (provider) => { mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.invalidEmbedderType", ) }) @@ -152,7 +160,7 @@ describe("CodeIndexServiceFactory", () => { }) mockConfigManager.getConfig.mockReturnValue(config) - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) expect(embedder).toBeInstanceOf(OpenAiEmbedder) expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -176,7 +184,7 @@ describe("CodeIndexServiceFactory", () => { }) mockConfigManager.getConfig.mockReturnValue(config) - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) expect(embedder).toBeInstanceOf(CodeIndexOllamaEmbedder) expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ ...ollamaOptions, ollamaModelId: "new-model" }) @@ -196,7 +204,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -218,7 +226,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -239,7 +247,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -260,7 +268,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -281,7 +289,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.openAiConfigMissing", ) }) @@ -298,7 +306,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.ollamaConfigMissing", ) }) @@ -317,7 +325,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -340,7 +348,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -363,7 +371,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( + "validation.baseUrlRequired", + ) }) it.each([undefined, ""])("rejects missing or empty OpenAI Compatible API key: %s", (apiKey) => { @@ -379,7 +389,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.apiKeyRequired") + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( + "validation.apiKeyRequired", + ) }) it("should throw error when OpenAI Compatible options are missing", () => { @@ -392,7 +404,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( + "validation.baseUrlRequired", + ) }) it("should create GeminiEmbedder with default model when no modelId specified", () => { @@ -406,7 +420,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined) @@ -424,7 +438,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") @@ -443,7 +457,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") @@ -460,7 +474,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.geminiConfigMissing", ) }) @@ -474,7 +488,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.geminiConfigMissing", ) }) @@ -492,7 +506,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", testModelId) @@ -510,7 +524,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.mistralConfigMissing", ) }) @@ -527,7 +541,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", undefined) @@ -546,7 +560,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", testModelId) @@ -564,7 +578,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.vercelAiGatewayConfigMissing", ) }) @@ -581,7 +595,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", undefined) @@ -601,10 +615,10 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert - expect(MockedBedrockEmbedder).toHaveBeenCalledWith("eu-west-1", "test-profile", testModelId) + expect(MockedBedrockEmbedder).toHaveBeenCalledWith("eu-west-1", "test-profile", testModelId, pacers) }) it("should pass undefined profile to Bedrock embedder when no profile is configured", () => { @@ -620,10 +634,10 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — profile omitted so the embedder falls back to the default credential chain. - expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", undefined, testModelId) + expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", undefined, testModelId, pacers) }) it("should throw error when Bedrock region is missing", () => { @@ -639,7 +653,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.bedrockConfigMissing", ) }) @@ -657,10 +671,10 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — modelId omitted so the embedder selects its documented default model. - expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", "test-profile", undefined) + expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", "test-profile", undefined, pacers) }) it("should pass API key, model ID and specific provider to OpenRouter embedder", () => { @@ -677,7 +691,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -701,7 +715,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — specificProvider omitted so the embedder routes to its default provider. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -724,7 +738,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.openRouterConfigMissing", ) }) @@ -742,7 +756,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - embedderFactory.create(mockConfigManager.getConfig()) + embedderFactory.create(mockConfigManager.getConfig(), pacers) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith("test-openrouter-key", undefined, undefined, "openai") @@ -757,7 +771,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.invalidEmbedderType", ) }) @@ -768,7 +782,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "Semble provider handles its own embedding. Do not call createEmbedder() for semble", ) }) @@ -793,7 +807,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", testModelId) @@ -802,6 +816,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 3072, "test-key", + pacers, ) }) @@ -818,7 +833,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("ollama", testModelId) @@ -827,6 +842,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 768, "test-key", + pacers, ) }) @@ -843,7 +859,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -852,6 +868,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 3072, "test-key", + pacers, ) }) @@ -875,7 +892,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(modelDimension) // This should be used // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -884,6 +901,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", modelDimension, // Should use model's built-in dimension, not manual "test-key", + pacers, ) }) @@ -906,7 +924,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Model has no built-in dimension // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -915,6 +933,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", manualDimension, // Should use manual dimension as fallback "test-key", + pacers, ) }) @@ -935,7 +954,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -944,6 +963,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 768, "test-key", + pacers, ) }) @@ -965,7 +985,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers)).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -987,7 +1007,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers)).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -1004,7 +1024,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001") @@ -1013,6 +1033,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 3072, "test-key", + pacers, ) }) @@ -1028,7 +1049,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetDefaultModelId).toHaveBeenCalledWith("gemini") @@ -1038,6 +1059,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 3072, "test-key", + pacers, ) }) @@ -1053,7 +1075,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act - vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers) // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "default-model") @@ -1062,6 +1084,7 @@ describe("CodeIndexServiceFactory", () => { "http://localhost:6333", 1536, "test-key", + pacers, ) }) @@ -1077,7 +1100,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers)).toThrow( "serviceFactory.vectorDimensionNotDetermined", ) }) @@ -1094,7 +1117,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act & Assert - expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers)).toThrow( "serviceFactory.qdrantUrlMissing", ) }) @@ -1105,7 +1128,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace", pacers)).toThrow( "Semble provider handles its own vector storage. Do not call createVectorStore() for semble", ) }) @@ -1136,7 +1159,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1163,7 +1186,7 @@ describe("CodeIndexServiceFactory", () => { }) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1189,7 +1212,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1214,7 +1237,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1237,7 +1260,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1262,7 +1285,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockRejectedValue(networkError) // Act - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) const result = await factory.validateEmbedder(embedder) // Assert @@ -1287,7 +1310,7 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder await expect(async () => { - const embedder = embedderFactory.create(mockConfigManager.getConfig()) + const embedder = embedderFactory.create(mockConfigManager.getConfig(), pacers) await factory.validateEmbedder(embedder) }).rejects.toThrow("serviceFactory.openAiConfigMissing") }) @@ -1302,7 +1325,7 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig(), pacers)).toThrow( "serviceFactory.invalidEmbedderType", ) }) diff --git a/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts b/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts index 838fffdc6e..f3a247096a 100644 --- a/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts +++ b/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts @@ -21,6 +21,9 @@ describe("WorkspaceIndexingEnablementManager", () => { workspacePath: "/workspace", workspaceEnabled: true, autoEnableDefault: false, + failedFiles: [], + failedFileSummary: undefined, + providerWaits: [], }), } return { diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index 7807aac69d..e3fd706525 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -5,6 +5,25 @@ import debounce from "lodash.debounce" import { safeWriteJson } from "../../utils/safeWriteJson" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import { t } from "../../i18n" +import { isSourceMissing } from "./shared/indexing-errors" + +/** The cache exists but could not be read; treating it as empty would erase the index on the next scan. */ +export class CacheReadError extends Error { + constructor(cause: unknown) { + super(t("embeddings:orchestrator.cacheReadFailed"), { cause }) + this.name = "CacheReadError" + } +} + +function isHashMap(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((hash) => typeof hash === "string") + ) +} /** * Manages the cache for code indexing @@ -33,19 +52,34 @@ export class CacheManager implements ICacheManager { * Initializes the cache manager by loading the cache file */ async initialize(): Promise { + let cacheData: Uint8Array + try { + cacheData = await vscode.workspace.fs.readFile(this.cachePath) + } catch (error) { + if (isSourceMissing(error)) { + this.fileHashes = {} + return + } + this.captureError(error, "initialize") + throw new CacheReadError(error) + } try { - const cacheData = await vscode.workspace.fs.readFile(this.cachePath) - this.fileHashes = JSON.parse(cacheData.toString()) + const parsed: unknown = JSON.parse(Buffer.from(cacheData).toString()) + this.fileHashes = isHashMap(parsed) ? parsed : {} } catch (error) { this.fileHashes = {} - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "initialize", - }) + this.captureError(error, "initialize") } } + private captureError(error: unknown, location: string): void { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location, + }) + } + /** * Saves the cache to disk */ diff --git a/src/services/code-index/code-index-scan-executor.ts b/src/services/code-index/code-index-scan-executor.ts index 54760c50f2..e0f38b4fff 100644 --- a/src/services/code-index/code-index-scan-executor.ts +++ b/src/services/code-index/code-index-scan-executor.ts @@ -1,9 +1,11 @@ import type { IDirectoryScanner, IVectorStore } from "./interfaces" import type { CodeIndexStateManager } from "./state-manager" -import { t } from "../../i18n" /** Executes scans; cancellation cleanup, watcher startup and completion remain with the orchestrator. */ export class CodeIndexScanExecutor { + /** True when the last scan finished with file-level failures; those files stay in the failure registry. */ + public partial = false + constructor( private readonly workspacePath: string, private readonly scanner: IDirectoryScanner, @@ -17,67 +19,46 @@ export class CodeIndexScanExecutor { "[CodeIndexOrchestrator] Collection already has indexed data. Running incremental scan for new/changed files...", ) this.stateManager.setSystemState("Indexing", "Checking for new or modified files...") - const summary = await this.scanWorkspace(signal, "incremental") - if (!summary) return false - - // Do not mark an existing index complete when some updates failed. - if (summary.batchErrors.length > 0) { - const messages = [...new Set(summary.batchErrors.map((error) => error.message))] - throw new AggregateError( - summary.batchErrors, - `Incremental scan failed with ${summary.batchErrors.length} errors:\n${messages.join("\n")}`, - ) - } - - if (summary.found > 0) { - console.log( - `[CodeIndexOrchestrator] Incremental scan completed: ${summary.indexed} blocks indexed from new/changed files`, - ) - } else { - console.log("[CodeIndexOrchestrator] No new or changed files found") - } - return true + return this.scanWorkspace(signal, "incremental") } /** Returns false on returned cancellation; scanner rejections propagate unchanged. */ async runFullScan(signal: AbortSignal): Promise { this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...") - const summary = await this.scanWorkspace(signal, "full") - if (!summary) return false - this.validateFullScan(summary.indexed, summary.found, summary.batchErrors) - return true + return this.scanWorkspace(signal, "full") } - private async scanWorkspace( - signal: AbortSignal, - kind: "full" | "incremental", - ): Promise<{ indexed: number; found: number; batchErrors: Error[] } | undefined> { + // File-level failures complete the scan with a partial index instead of failing the whole attempt. + private async scanWorkspace(signal: AbortSignal, kind: "full" | "incremental"): Promise { + this.partial = false await this.vectorStore.markIndexingIncomplete() let indexed = 0 let found = 0 - const batchErrors: Error[] = [] + let failureCount = 0 const result = await this.scanner.scanDirectory( this.workspacePath, - (batchError: Error) => { + (fileError: Error) => { console.error( - `[CodeIndexOrchestrator] Error during ${kind === "full" ? "initial" : "incremental"} scan batch: ${batchError.message}`, - batchError, + `[CodeIndexOrchestrator] Error during ${kind === "full" ? "initial" : "incremental"} scan: ${fileError.message}`, + fileError, ) - batchErrors.push(batchError) + failureCount++ }, (count: number) => { + if (signal.aborted) return indexed += count this.stateManager.reportBlockIndexingProgress(indexed, found) }, (count: number) => { + if (signal.aborted) return found += count this.stateManager.reportBlockIndexingProgress(indexed, found) }, signal, ) - // Cancellation precedes missing-result checks and validation, just as in the orchestrator. - if (signal.aborted) return undefined + // Cancellation precedes missing-result checks, just as in the orchestrator. + if (signal.aborted) return false if (!result) { throw new Error( kind === "full" @@ -85,25 +66,8 @@ export class CodeIndexScanExecutor { : "Incremental scan failed, is scanner initialized?", ) } - return { indexed, found, batchErrors } - } - - private validateFullScan(indexed: number, found: number, batchErrors: Error[]): void { - const firstError = batchErrors[0] - if (indexed === 0 && found > 0) { - throw new Error( - firstError - ? `Indexing failed: ${firstError.message}` - : t("embeddings:orchestrator.indexingFailedNoBlocks"), - ) - } - if (firstError && (found - indexed) / found > 0.1) { - throw new Error( - `Indexing partially failed: Only ${indexed} of ${found} blocks were indexed. ${firstError.message}`, - ) - } - if (firstError && indexed === 0) { - throw new Error(`Indexing failed completely: ${firstError.message}`) - } + this.partial = failureCount > 0 || indexed < found + console.log(`[CodeIndexOrchestrator] ${kind} scan completed: ${indexed} of ${found} blocks indexed`) + return true } } diff --git a/src/services/code-index/constants/index.ts b/src/services/code-index/constants/index.ts index 6f0e0fe7e6..921ee8bc45 100644 --- a/src/services/code-index/constants/index.ts +++ b/src/services/code-index/constants/index.ts @@ -17,10 +17,10 @@ export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB /**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 -export const MAX_BATCH_RETRIES = 3 -export const INITIAL_RETRY_DELAY_MS = 500 +export const FILE_RETRY_ATTEMPTS = 3 +export const FILE_RETRY_BASE_DELAY_MS = 500 +export const PACER_MAX_BACKOFF_MS = 300_000 export const PARSING_CONCURRENCY = 10 -export const MAX_PENDING_BATCHES = 20 // Maximum number of batches to accumulate before waiting /**OpenAI Embedder */ export const MAX_BATCH_TOKENS = 100000 diff --git a/src/services/code-index/embedders/__tests__/bedrock.spec.ts b/src/services/code-index/embedders/__tests__/bedrock.spec.ts index f089fece39..97ff7f62e5 100644 --- a/src/services/code-index/embedders/__tests__/bedrock.spec.ts +++ b/src/services/code-index/embedders/__tests__/bedrock.spec.ts @@ -1,8 +1,10 @@ -import type { MockedFunction } from "vitest" import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime" import { BedrockEmbedder } from "../bedrock" -import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { ProviderPacerRegistry } from "../../provider-pacer" + +const pacers = new ProviderPacerRegistry() +import { MAX_ITEM_TOKENS } from "../../constants" import { createProxyRoutingRequestHandler } from "../../../../utils/networkProxy" import { clearAllMocks } from "../../../../test-utils/reset" @@ -23,7 +25,7 @@ vitest.mock("@aws-sdk/client-bedrock-runtime", () => { } }) vitest.mock("@aws-sdk/credential-providers", () => ({ - fromEnv: vitest.fn().mockReturnValue(Promise.resolve({})), + fromNodeProviderChain: vitest.fn().mockReturnValue(Promise.resolve({})), fromIni: vitest.fn().mockReturnValue(Promise.resolve({})), })) @@ -42,13 +44,13 @@ vitest.mock("@roo-code/telemetry", () => ({ // Mock i18n vitest.mock("../../../../i18n", () => ({ - t: (key: string, params?: Record) => { + t: (key: string, params?: Record) => { const translations: Record = { "embeddings:authenticationFailed": "Failed to create embeddings: Authentication failed. Please check your AWS credentials.", - "embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`, - "embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`, - "embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`, + "embeddings:failedWithStatus": `Failed to create embeddings: HTTP ${params?.statusCode} - ${params?.errorMessage}`, + "embeddings:failedWithError": `Failed to create embeddings: ${params?.errorMessage}`, + "embeddings:failedMaxAttempts": `Failed to create embeddings`, "embeddings:textExceedsTokenLimit": `Text at index ${params?.index} exceeds maximum token limit (${params?.itemTokens} > ${params?.maxTokens}). Skipping.`, "embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`, "embeddings:bedrock.invalidResponseFormat": "Invalid response format from Bedrock", @@ -72,7 +74,7 @@ const consoleMocks = { describe("BedrockEmbedder", () => { let embedder: BedrockEmbedder - let mockSend: MockedFunction + let mockSend: ReturnType Promise>> beforeEach(() => { clearAllMocks() @@ -82,14 +84,13 @@ describe("BedrockEmbedder", () => { mockSend = vitest.fn() // Set up the mock implementation - const MockedBedrockRuntimeClient = BedrockRuntimeClient as any + const MockedBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) MockedBedrockRuntimeClient.mockImplementation(function () { - return { - send: mockSend, - } + // This client double supplies only the send method used by the embedder. + return { send: mockSend } as unknown as BedrockRuntimeClient }) - embedder = new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0") + embedder = new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0", pacers) }) afterEach(() => { @@ -107,13 +108,13 @@ describe("BedrockEmbedder", () => { }) it("should require region", () => { - expect(() => new BedrockEmbedder("", "profile", "model")).toThrow( + expect(() => new BedrockEmbedder("", "profile", "model", pacers)).toThrow( "Region is required for AWS Bedrock embedder", ) }) it("should use profile for credentials", () => { - const profileEmbedder = new BedrockEmbedder("us-west-2", "dev-profile") + const profileEmbedder = new BedrockEmbedder("us-west-2", "dev-profile", undefined, pacers) expect(profileEmbedder).toBeDefined() }) @@ -134,7 +135,7 @@ describe("BedrockEmbedder", () => { } vitest.mocked(createProxyRoutingRequestHandler).mockReturnValue(handler) - new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0") + new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0", pacers) const clientConfig = vitest.mocked(BedrockRuntimeClient).mock.calls.at(-1)?.[0] expect(clientConfig?.requestHandler).toBe(handler) @@ -146,7 +147,7 @@ describe("BedrockEmbedder", () => { it("should keep the client default handler when no proxy is configured", () => { vitest.mocked(createProxyRoutingRequestHandler).mockReturnValue(undefined) - new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0") + new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0", pacers) expect(BedrockRuntimeClient).toHaveBeenLastCalledWith( expect.not.objectContaining({ requestHandler: expect.anything() }), @@ -172,7 +173,7 @@ describe("BedrockEmbedder", () => { const result = await embedder.createEmbeddings(testTexts) expect(mockSend).toHaveBeenCalled() - const command = mockSend.mock.calls[0][0] as any + const command = mockSend.mock.calls[0][0] as InvokeModelCommand expect(command.input.modelId).toBe(testModelId) const bodyStr = typeof command.input.body === "string" @@ -224,7 +225,7 @@ describe("BedrockEmbedder", () => { }) it("should handle Cohere model format", async () => { - const cohereEmbedder = new BedrockEmbedder("us-east-1", "test-profile", "cohere.embed-english-v3") + const cohereEmbedder = new BedrockEmbedder("us-east-1", "test-profile", "cohere.embed-english-v3", pacers) const testTexts = ["Hello world"] const mockResponse = { body: new TextEncoder().encode( @@ -258,6 +259,7 @@ describe("BedrockEmbedder", () => { "us-east-1", "test-profile", "amazon.nova-2-multimodal-embeddings-v1:0", + pacers, ) const testTexts = ["Hello world"] const mockResponse = { @@ -277,7 +279,7 @@ describe("BedrockEmbedder", () => { const result = await novaMultimodalEmbedder.createEmbeddings(testTexts) expect(mockSend).toHaveBeenCalled() - const command = mockSend.mock.calls[0][0] as any + const command = mockSend.mock.calls[0][0] as InvokeModelCommand expect(command.input.modelId).toBe("amazon.nova-2-multimodal-embeddings-v1:0") const bodyStr = typeof command.input.body === "string" @@ -307,6 +309,7 @@ describe("BedrockEmbedder", () => { "us-east-1", "test-profile", "amazon.nova-2-multimodal-embeddings-v1:0", + pacers, ) const testTexts = ["Hello world", "Another text"] const mockResponses = [ @@ -343,7 +346,7 @@ describe("BedrockEmbedder", () => { expect(mockSend).toHaveBeenCalledTimes(2) // Verify the request format for both texts - const firstCommand = mockSend.mock.calls[0][0] as any + const firstCommand = mockSend.mock.calls[0][0] as InvokeModelCommand const firstBodyStr = typeof firstCommand.input.body === "string" ? firstCommand.input.body @@ -361,7 +364,7 @@ describe("BedrockEmbedder", () => { }, }) - const secondCommand = mockSend.mock.calls[1][0] as any + const secondCommand = mockSend.mock.calls[1][0] as InvokeModelCommand const secondBodyStr = typeof secondCommand.input.body === "string" ? secondCommand.input.body @@ -493,37 +496,16 @@ describe("BedrockEmbedder", () => { vitest.useRealTimers() }) - it("should retry on throttling errors with exponential backoff", async () => { - const testTexts = ["Hello world"] - const throttlingError = new Error("Rate limit exceeded") - throttlingError.name = "ThrottlingException" - - mockSend - .mockRejectedValueOnce(throttlingError) - .mockRejectedValueOnce(throttlingError) - .mockResolvedValueOnce({ - body: new TextEncoder().encode( - JSON.stringify({ - embedding: [0.1, 0.2, 0.3], - inputTextTokenCount: 2, - }), - ), - }) - - const resultPromise = embedder.createEmbeddings(testTexts) - - // Fast-forward through the delays - await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS) // First retry delay - await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS * 2) // Second retry delay - - const result = await resultPromise - - expect(mockSend).toHaveBeenCalledTimes(3) - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limit hit, retrying in")) - expect(result).toEqual({ - embeddings: [[0.1, 0.2, 0.3]], - usage: { promptTokens: 2, totalTokens: 2 }, + it("returns rate limits without internal retries", async () => { + const cause = Object.assign(new Error("rate limited"), { status: 429, name: "ThrottlingException" }) + mockSend.mockRejectedValue(cause) + await expect(embedder.createEmbeddings(["test"])).rejects.toMatchObject({ + classification: "rate-limit", + statusCode: 429, + cause, }) + expect(mockSend).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) }) it("should not retry on non-throttling errors", async () => { @@ -534,7 +516,7 @@ describe("BedrockEmbedder", () => { mockSend.mockRejectedValue(authError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Unauthorized", + "Failed to create embeddings: Unauthorized", ) expect(mockSend).toHaveBeenCalledTimes(1) @@ -553,7 +535,7 @@ describe("BedrockEmbedder", () => { mockSend.mockRejectedValue(apiError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: API connection failed", + "Failed to create embeddings: API connection failed", ) expect(console.error).toHaveBeenCalledWith( @@ -594,7 +576,7 @@ describe("BedrockEmbedder", () => { mockSend.mockRejectedValueOnce(authError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Invalid credentials", + "Failed to create embeddings: Invalid credentials", ) // Test AccessDeniedException @@ -603,7 +585,7 @@ describe("BedrockEmbedder", () => { mockSend.mockRejectedValueOnce(accessError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Access denied", + "Failed to create embeddings: Access denied", ) // Test ResourceNotFoundException @@ -612,7 +594,7 @@ describe("BedrockEmbedder", () => { mockSend.mockRejectedValueOnce(notFoundError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Model not found", + "Failed to create embeddings: Model not found", ) }) }) @@ -697,6 +679,52 @@ describe("BedrockEmbedder", () => { expect(result.error).toBe("Connection failed") }) + describe("CI-EM-02: temporary failures", () => { + const okResponse = { body: new TextEncoder().encode(JSON.stringify({ embedding: [0.1] })) } + const serverError = () => + Object.assign(new Error("Service unavailable"), { $metadata: { httpStatusCode: 503 } }) + const throttle = () => Object.assign(new Error("Slow down"), { name: "ThrottlingException" }) + + beforeEach(() => { + vitest.useFakeTimers() + // A shared registry would carry the throttle case's pause into the next case. + embedder = new BedrockEmbedder( + "us-east-1", + "test-profile", + "amazon.titan-embed-text-v2:0", + new ProviderPacerRegistry(), + ) + }) + afterEach(() => vitest.useRealTimers()) + + it.each([ + ["two server failures", [serverError(), serverError()], 1500], + ["a throttle", [throttle()], 1000], + ])("recovers after %s and reports success", async (_label, failures, waitMs) => { + for (const failure of failures) mockSend.mockRejectedValueOnce(failure) + mockSend.mockResolvedValueOnce(okResponse) + const pending = embedder.validateConfiguration() + await vitest.advanceTimersByTimeAsync(waitMs) + await expect(pending).resolves.toEqual({ valid: true }) + expect(mockSend).toHaveBeenCalledTimes(failures.length + 1) + }) + + it("stops after three requests and returns a validation failure", async () => { + mockSend.mockRejectedValue(serverError()) + const pending = embedder.validateConfiguration() + await vitest.advanceTimersByTimeAsync(1500) + const result = await pending + expect(result.valid).toBe(false) + expect(mockSend).toHaveBeenCalledTimes(3) + }) + + it("does not retry a permanent failure", async () => { + mockSend.mockRejectedValue(Object.assign(new Error("Denied"), { name: "AccessDeniedException" })) + await expect(embedder.validateConfiguration()).resolves.toMatchObject({ valid: false }) + expect(mockSend).toHaveBeenCalledTimes(1) + }) + }) + it("should fail validation with generic error", async () => { const genericError = new Error("Unknown error") mockSend.mockRejectedValue(genericError) diff --git a/src/services/code-index/embedders/__tests__/embedder-factory.spec.ts b/src/services/code-index/embedders/__tests__/embedder-factory.spec.ts new file mode 100644 index 0000000000..b1e43ff292 --- /dev/null +++ b/src/services/code-index/embedders/__tests__/embedder-factory.spec.ts @@ -0,0 +1,23 @@ +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { EmbedderFactory } from "../embedder-factory" +import { BedrockEmbedder } from "../bedrock" +import { ProviderPacerRegistry } from "../../provider-pacer" +import type { CodeIndexConfig } from "../../interfaces/config" + +vi.mock("../bedrock", () => ({ BedrockEmbedder: vi.fn() })) + +describe("EmbedderFactory (CI-EM-03)", () => { + const config: CodeIndexConfig = { + isConfigured: true, + embedderProvider: providerIdentifiers.bedrock, + modelId: "amazon.titan-embed-text-v2:0", + bedrockOptions: { region: "us-east-1", profile: "dev" }, + } + + it("passes the workspace pacer registry through to BedrockEmbedder", () => { + const pacers = new ProviderPacerRegistry() + new EmbedderFactory().create(config, pacers) + expect(BedrockEmbedder).toHaveBeenLastCalledWith("us-east-1", "dev", "amazon.titan-embed-text-v2:0", pacers) + }) +}) diff --git a/src/services/code-index/embedders/__tests__/ollama.spec.ts b/src/services/code-index/embedders/__tests__/ollama.spec.ts index 40f745b70a..3decb72413 100644 --- a/src/services/code-index/embedders/__tests__/ollama.spec.ts +++ b/src/services/code-index/embedders/__tests__/ollama.spec.ts @@ -326,4 +326,21 @@ describe("CodeIndexOllamaEmbedder", () => { expect(result.error).toBe("Network timeout") }) }) + + describe("CI-EM-01: single attempt with classified errors", () => { + it.each([ + [503, undefined, "server", undefined], + [429, "7", "rate-limit", 7000], + ])("sends one request on HTTP %s and classifies it", async (status, retryAfter, classification, delay) => { + mockFetch.mockResolvedValueOnce( + new Response("busy", { + status, + headers: retryAfter === undefined ? {} : { "retry-after": retryAfter }, + }), + ) + const error = await embedder.createEmbeddings(["text"]).catch((caught: unknown) => caught) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(error).toMatchObject({ classification, statusCode: status, retryAfterMs: delay }) + }) + }) }) diff --git a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts index 49086d675b..996e1cb398 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts @@ -1,217 +1,54 @@ -import type { MockedClass, MockedFunction } from "vitest" -import { OpenAI } from "openai" - -import { OpenAICompatibleEmbedder } from "../openai-compatible" -import { clearAllMocks, restoreGlobals } from "../../../../test-utils/reset" - -// Mock the OpenAI SDK -vi.mock("openai") - -// Mock TelemetryService vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureEvent: vi.fn(), - }, - }, -})) - -// Mock i18n -vi.mock("../../../../i18n", () => ({ - t: (key: string, params?: Record) => { - const translations: Record = { - "embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`, - "embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`, - "embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`, - "embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`, - } - return translations[key] || key - }, + TelemetryService: { instance: { captureEvent: vi.fn() } }, })) +import { OpenAI } from "openai" +import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { OpenRouterEmbedder } from "../openrouter" -const MockedOpenAI = OpenAI as MockedClass - -describe("OpenAICompatibleEmbedder - Global Rate Limiting", () => { - let mockOpenAIInstance: any - let mockEmbeddingsCreate: MockedFunction - - const testBaseUrl = "https://api.openai.com/v1" - const testApiKey = "test-api-key" - const testModelId = "text-embedding-3-small" - - beforeEach(() => { - clearAllMocks() - vi.useFakeTimers() - vi.spyOn(console, "warn").mockImplementation(function () {}) - vi.spyOn(console, "error").mockImplementation(function () {}) - - // Setup mock OpenAI instance - mockEmbeddingsCreate = vi.fn() - mockOpenAIInstance = { - embeddings: { - create: mockEmbeddingsCreate, - }, - } - - MockedOpenAI.mockImplementation(function () { - return mockOpenAIInstance - }) - - // Reset global rate limit state - const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - ;(embedder as any).constructor.globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - mutex: (embedder as any).constructor.globalRateLimitState.mutex, - } - }) - - afterEach(() => { - vi.useRealTimers() - restoreGlobals() - }) - - it("should apply global rate limiting across multiple batch requests", async () => { - const embedder1 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - const embedder2 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - - // First batch hits rate limit - const rateLimitError = new Error("Rate limit exceeded") as any - rateLimitError.status = 429 - - mockEmbeddingsCreate - .mockRejectedValueOnce(rateLimitError) // First attempt fails - .mockResolvedValue({ - data: [{ embedding: "base64encodeddata" }], - usage: { prompt_tokens: 10, total_tokens: 15 }, - }) - - // Start first batch request - const batch1Promise = embedder1.createEmbeddings(["test1"]) - - // Advance time slightly to let the first request fail and set global rate limit - await vi.advanceTimersByTimeAsync(100) - - // Start second batch request while global rate limit is active - const batch2Promise = embedder2.createEmbeddings(["test2"]) - - // Check that global rate limit was set - const state = (embedder1 as any).constructor.globalRateLimitState - expect(state.isRateLimited).toBe(true) - expect(state.consecutiveRateLimitErrors).toBe(1) - - // Advance time to complete rate limit delay (5 seconds base delay) - await vi.advanceTimersByTimeAsync(5000) - - // Both requests should complete - const [result1, result2] = await Promise.all([batch1Promise, batch2Promise]) - - expect(result1.embeddings).toHaveLength(1) - expect(result2.embeddings).toHaveLength(1) - - // The second embedder should have waited for the global rate limit - // No logging expected - we've removed it to prevent log flooding - }) - - it("should track consecutive rate limit errors", async () => { - const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - const state = (embedder as any).constructor.globalRateLimitState - - const rateLimitError = new Error("Rate limit exceeded") as any - rateLimitError.status = 429 - - // Test that consecutive errors increment when they happen quickly - // Mock multiple rate limit errors in a single request - mockEmbeddingsCreate - .mockRejectedValueOnce(rateLimitError) // First attempt - .mockRejectedValueOnce(rateLimitError) // Retry 1 - .mockResolvedValueOnce({ - data: [{ embedding: "base64encodeddata" }], - usage: { prompt_tokens: 10, total_tokens: 15 }, - }) - - const promise1 = embedder.createEmbeddings(["test1"]) - - // Wait for first attempt to fail - await vi.advanceTimersByTimeAsync(100) - expect(state.consecutiveRateLimitErrors).toBe(1) +import { ProviderPacerRegistry } from "../../provider-pacer" - // Wait for first retry (500ms) to also fail - await vi.advanceTimersByTimeAsync(500) +vi.mock("openai") +vi.mock("../../../../i18n", () => ({ t: (key: string) => key })) - // The state should show 2 consecutive errors now - // Note: The count might be 1 if the global rate limit kicked in before the second attempt - expect(state.consecutiveRateLimitErrors).toBeGreaterThanOrEqual(1) +const factories = [ + ["OpenAI-compatible", () => new OpenAICompatibleEmbedder("https://example.test/v1", "test-key")], + ["OpenRouter", () => new OpenRouterEmbedder("test-key")], +] as const - // Wait for the global rate limit and successful retry - await vi.advanceTimersByTimeAsync(20000) - await promise1 +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) - // Verify the delay increases with consecutive errors - // Make another request immediately that also hits rate limit - mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ - data: [{ embedding: "base64encodeddata" }], - usage: { prompt_tokens: 10, total_tokens: 15 }, +describe.each(factories)("%s rate-limit boundary", (_name, createEmbedder) => { + it("shares a workspace pause across instances without adding wrapper retries", async () => { + const request = vi.fn() + // This client double supplies only embeddings.create, the embedder transport. + vi.mocked(OpenAI).mockImplementation(function () { + return { embeddings: { create: request } } as unknown as OpenAI }) - - // Store the current consecutive count before the next request - const previousCount = state.consecutiveRateLimitErrors - - const promise2 = embedder.createEmbeddings(["test2"]) - await vi.advanceTimersByTimeAsync(100) - - // Should have incremented from the previous count - expect(state.consecutiveRateLimitErrors).toBeGreaterThan(previousCount) - - // Complete the second request - await vi.advanceTimersByTimeAsync(20000) - await promise2 - }) - - it("should reset consecutive error count after time passes", async () => { - const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - const state = (embedder as any).constructor.globalRateLimitState - - // Manually set state to simulate previous errors - state.consecutiveRateLimitErrors = 3 - state.lastRateLimitError = Date.now() - 70000 // 70 seconds ago - - const rateLimitError = new Error("Rate limit exceeded") as any - rateLimitError.status = 429 - - mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ - data: [{ embedding: "base64encodeddata" }], - usage: { prompt_tokens: 10, total_tokens: 15 }, + const cause = Object.assign(new Error("rate limited"), { + status: 429, + headers: new Headers({ "retry-after": "60" }), }) - - // Trigger the updateGlobalRateLimitState method - await (embedder as any).updateGlobalRateLimitState(rateLimitError) - - // Should reset to 1 since more than 60 seconds passed - expect(state.consecutiveRateLimitErrors).toBe(1) - }) - - it("should not exceed maximum delay of 5 minutes", async () => { - const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - const state = (embedder as any).constructor.globalRateLimitState - - // Set state to simulate many consecutive errors - state.consecutiveRateLimitErrors = 10 // This would normally result in a very long delay - - const rateLimitError = new Error("Rate limit exceeded") as any - rateLimitError.status = 429 - - // Trigger the updateGlobalRateLimitState method - await (embedder as any).updateGlobalRateLimitState(rateLimitError) - - // Calculate the expected delay - const now = Date.now() - const delay = state.rateLimitResetTime - now - - // Should be capped at 5 minutes (300000ms) - expect(delay).toBeLessThanOrEqual(300000) - expect(delay).toBeGreaterThan(0) + request.mockRejectedValueOnce(cause).mockResolvedValue({ data: [{ embedding: [0.25] }] }) + const first = createEmbedder() + const second = createEmbedder() + const registry = new ProviderPacerRegistry() + const pacer = registry.get(first.rateLimitScope) + const firstResult = pacer.run("first.ts", undefined, () => first.createEmbeddings(["first"])) + await vi.advanceTimersByTimeAsync(0) + const secondResult = pacer.run("second.ts", undefined, () => second.createEmbeddings(["second"])) + await vi.advanceTimersByTimeAsync(59_999) + expect(request).toHaveBeenCalledOnce() + expect(first.rateLimitScope).toBe(second.rateLimitScope) + await vi.advanceTimersByTimeAsync(1) + await expect(firstResult).resolves.toMatchObject({ embeddings: [[0.25]] }) + await vi.advanceTimersByTimeAsync(375) + await expect(secondResult).resolves.toMatchObject({ embeddings: [[0.25]] }) + expect(request).toHaveBeenCalledTimes(3) }) }) diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index 859f2ceae1..f163c876b7 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -21,13 +21,13 @@ vitest.mock("@roo-code/telemetry", () => ({ // Mock i18n vitest.mock("../../../../i18n", () => ({ - t: (key: string, params?: Record) => { + t: (key: string, params?: Record) => { const translations: Record = { "embeddings:authenticationFailed": "Failed to create embeddings: Authentication failed. Please check your API key.", - "embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`, - "embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`, - "embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`, + "embeddings:failedWithStatus": `Failed to create embeddings: HTTP ${params?.statusCode} - ${params?.errorMessage}`, + "embeddings:failedWithError": `Failed to create embeddings: ${params?.errorMessage}`, + "embeddings:failedMaxAttempts": `Failed to create embeddings`, "embeddings:textExceedsTokenLimit": `Text at index ${params?.index} exceeds maximum token limit (${params?.itemTokens} > ${params?.maxTokens}). Skipping.`, "embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`, "embeddings:unknownError": "Unknown error", @@ -55,8 +55,8 @@ const MockedOpenAI = OpenAI as MockedClass describe("OpenAICompatibleEmbedder", () => { let embedder: OpenAICompatibleEmbedder - let mockOpenAIInstance: any - let mockEmbeddingsCreate: MockedFunction + let mockOpenAIInstance: { embeddings: { create: typeof mockEmbeddingsCreate } } + let mockEmbeddingsCreate: ReturnType Promise>> const testBaseUrl = "https://api.example.com/v1" const testApiKey = "test-api-key" @@ -76,18 +76,9 @@ describe("OpenAICompatibleEmbedder", () => { } MockedOpenAI.mockImplementation(function () { - return mockOpenAIInstance + // The embedder only uses embeddings.create on this client double. + return mockOpenAIInstance as unknown as OpenAI }) - - // Reset global rate limit state to prevent interference between tests - const tempEmbedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) - ;(tempEmbedder as any).constructor.globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - mutex: (tempEmbedder as any).constructor.globalRateLimitState.mutex, - } }) afterEach(() => { @@ -101,6 +92,7 @@ describe("OpenAICompatibleEmbedder", () => { expect(MockedOpenAI).toHaveBeenCalledWith({ baseURL: testBaseUrl, apiKey: testApiKey, + maxRetries: 0, }) expect(embedder).toBeDefined() }) @@ -111,6 +103,7 @@ describe("OpenAICompatibleEmbedder", () => { expect(MockedOpenAI).toHaveBeenCalledWith({ baseURL: testBaseUrl, apiKey: testApiKey, + maxRetries: 0, }) expect(embedder).toBeDefined() }) @@ -411,50 +404,22 @@ describe("OpenAICompatibleEmbedder", () => { vitest.useRealTimers() }) - it("should retry on rate limit errors with exponential backoff", async () => { - const testTexts = ["Hello world"] - const rateLimitError = { status: 429, message: "Rate limit exceeded" } - - // Create base64 encoded embedding for successful response - const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) - const base64String = Buffer.from(testEmbedding.buffer).toString("base64") - - mockEmbeddingsCreate - .mockRejectedValueOnce(rateLimitError) - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ - data: [{ embedding: base64String }], - usage: { prompt_tokens: 10, total_tokens: 15 }, - }) - - const resultPromise = embedder.createEmbeddings(testTexts) - - // First attempt fails immediately, triggering global rate limit (5s) - await vitest.advanceTimersByTimeAsync(100) - - // Wait for global rate limit delay - await vitest.advanceTimersByTimeAsync(5000) - - // Second attempt also fails, increasing delay - await vitest.advanceTimersByTimeAsync(100) - - // Wait for increased global rate limit delay (10s) - await vitest.advanceTimersByTimeAsync(10000) - - const result = await resultPromise - - expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limit hit, retrying in")) - expect(result).toEqual({ - embeddings: [[0.25, 0.5, 0.75]], - usage: { promptTokens: 10, totalTokens: 15 }, + it("returns rate limits without internal retries", async () => { + const cause = Object.assign(new Error("rate limited"), { status: 429, name: "ThrottlingException" }) + mockEmbeddingsCreate.mockRejectedValue(cause) + await expect(embedder.createEmbeddings(["test"])).rejects.toMatchObject({ + classification: "rate-limit", + statusCode: 429, + cause, }) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) }) it("should not retry on non-rate-limit errors", async () => { const testTexts = ["Hello world"] const authError = new Error("Unauthorized") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) @@ -469,12 +434,12 @@ describe("OpenAICompatibleEmbedder", () => { it("should throw error immediately on non-retryable errors", async () => { const testTexts = ["Hello world"] const serverError = new Error("Internal server error") - ;(serverError as any).status = 500 + Object.assign(serverError, { status: 500 }) mockEmbeddingsCreate.mockRejectedValue(serverError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: HTTP 500 - Internal server error", + "Failed to create embeddings: HTTP 500 - Internal server error", ) expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) @@ -492,7 +457,7 @@ describe("OpenAICompatibleEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(apiError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: API connection failed", + "Failed to create embeddings: API connection failed", ) expect(console.error).toHaveBeenCalledWith( @@ -508,7 +473,7 @@ describe("OpenAICompatibleEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(batchError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Batch processing failed", + "Failed to create embeddings: Batch processing failed", ) expect(console.error).toHaveBeenCalledWith( @@ -544,7 +509,7 @@ describe("OpenAICompatibleEmbedder", () => { it("should provide specific authentication error message", async () => { const testTexts = ["Hello world"] const authError = new Error("Invalid API key") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) @@ -556,12 +521,12 @@ describe("OpenAICompatibleEmbedder", () => { it("should provide detailed error message for HTTP errors", async () => { const testTexts = ["Hello world"] const httpError = new Error("Bad request") - ;(httpError as any).status = 400 + Object.assign(httpError, { status: 400 }) mockEmbeddingsCreate.mockRejectedValue(httpError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: HTTP 400 - Bad request", + "Failed to create embeddings: HTTP 400 - Bad request", ) }) @@ -572,7 +537,7 @@ describe("OpenAICompatibleEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(networkError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Network timeout", + "Failed to create embeddings: Network timeout", ) }) @@ -583,7 +548,7 @@ describe("OpenAICompatibleEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(weirdError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Custom error object", + "Failed to create embeddings: Custom error object", ) }) @@ -594,88 +559,28 @@ describe("OpenAICompatibleEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(unknownError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Unknown error", + "Failed to create embeddings: Unknown error", ) }) }) - /** - * Test to confirm OpenAI package bug with base64 encoding - * This test verifies that when we request encoding_format: "base64", - * the OpenAI package returns unparsed base64 strings as expected. - * This is the behavior we rely on in our workaround. - */ - describe("OpenAI package base64 behavior verification", () => { - it("should return unparsed base64 when encoding_format is base64", async () => { - const testTexts = ["Hello world"] - - // Create a real OpenAI instance to test the actual package behavior - const realOpenAI = new ((await vi.importActual("openai")) as any).OpenAI({ - baseURL: testBaseUrl, - apiKey: testApiKey, - }) - - // Create test embedding data as base64 using values that can be exactly represented in Float32 - const testEmbedding = new Float32Array([0.25, 0.5, 0.75, 1.0]) - const buffer = Buffer.from(testEmbedding.buffer) - const base64String = buffer.toString("base64") - - // Mock the raw API response that would come from OpenAI - const mockApiResponse = { - data: [ - { - object: "embedding", - embedding: base64String, // Raw base64 string from API - index: 0, - }, - ], - model: "text-embedding-3-small", - object: "list", - usage: { - prompt_tokens: 2, - total_tokens: 2, - }, - } - - // Mock the methodRequest method which is called by post() - const mockMethodRequest = vi.fn() - const mockAPIPromise = { - then: vi.fn().mockImplementation((callback) => { - return Promise.resolve(callback(mockApiResponse)) + it("keeps base64 strings unparsed at the SDK boundary", async () => { + const { OpenAI: ActualOpenAI } = await vi.importActual("openai") + const encodedEmbedding = Buffer.from(new Float32Array([0.25, 0.5]).buffer).toString("base64") + const client = new ActualOpenAI({ + apiKey: testApiKey, + maxRetries: 0, + fetch: async () => + new Response(JSON.stringify({ data: [{ embedding: encodedEmbedding }] }), { + headers: { "content-type": "application/json" }, }), - catch: vi.fn(), - finally: vi.fn(), - } - mockMethodRequest.mockReturnValue(mockAPIPromise) - - // Replace the methodRequest method on the client - ;(realOpenAI as any).post = vi.fn().mockImplementation((path, opts) => { - return mockMethodRequest("post", path, opts) - }) - - // Call the embeddings.create method with base64 encoding - const response = await realOpenAI.embeddings.create({ - input: testTexts, - model: "text-embedding-3-small", - encoding_format: "base64", - }) - - // Verify that the response contains the raw base64 string - // This confirms the OpenAI package doesn't parse base64 when explicitly requested - expect(response.data[0].embedding).toBe(base64String) - expect(typeof response.data[0].embedding).toBe("string") - - // Verify we can manually convert it back to the original float array - const returnedBuffer = Buffer.from(response.data[0].embedding as string, "base64") - const returnedFloat32Array = new Float32Array( - returnedBuffer.buffer, - returnedBuffer.byteOffset, - returnedBuffer.byteLength / 4, - ) - const returnedArray = Array.from(returnedFloat32Array) - - expect(returnedArray).toEqual([0.25, 0.5, 0.75, 1.0]) }) + const response = await client.embeddings.create({ + input: ["test"], + model: testModelId, + encoding_format: "base64", + }) + expect(response.data[0].embedding).toBe(encodedEmbedding) }) /** @@ -687,12 +592,8 @@ describe("OpenAICompatibleEmbedder", () => { const baseUrl = "https://api.openai.com/v1" // Helper to create mock fetch response - const createMockResponse = (data: any, status = 200, ok = true) => ({ - ok, - status, - json: vitest.fn().mockResolvedValue(data), - text: vitest.fn().mockResolvedValue(status === 200 ? "" : "Error message"), - }) + const createMockResponse = (data: unknown, status = 200, _ok = true) => + new Response(status === 200 ? JSON.stringify(data) : "Error message", { status }) // Helper to create base64 embedding const createBase64Embedding = (values: number[]) => { @@ -723,7 +624,7 @@ describe("OpenAICompatibleEmbedder", () => { ["http://localhost:8080", false], ])("should detect URL type correctly: %s -> %s", (url, expected) => { const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) - const isFullUrl = (embedder as any).isFullEndpointUrl(url) + const isFullUrl = embedder["isFullEndpointUrl"](url) expect(isFullUrl).toBe(expected) }) @@ -738,7 +639,7 @@ describe("OpenAICompatibleEmbedder", () => { testUrls.forEach((url) => { const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) - const isFullUrl = (embedder as any).isFullEndpointUrl(url) + const isFullUrl = embedder["isFullEndpointUrl"](url) expect(isFullUrl).toBe(false) }) }) @@ -753,7 +654,7 @@ describe("OpenAICompatibleEmbedder", () => { testUrls.forEach((url) => { const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) - const isFullUrl = (embedder as any).isFullEndpointUrl(url) + const isFullUrl = embedder["isFullEndpointUrl"](url) expect(isFullUrl).toBe(false) }) }) @@ -769,7 +670,7 @@ describe("OpenAICompatibleEmbedder", () => { endpointUrls.forEach((url) => { const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) - const isFullUrl = (embedder as any).isFullEndpointUrl(url) + const isFullUrl = embedder["isFullEndpointUrl"](url) expect(isFullUrl).toBe(true) }) }) @@ -786,7 +687,7 @@ describe("OpenAICompatibleEmbedder", () => { data: [{ embedding: base64String }], usage: { prompt_tokens: 10, total_tokens: 15 }, }) - ;(global.fetch as MockedFunction).mockResolvedValue(mockFetchResponse as any) + ;(global.fetch as MockedFunction).mockResolvedValue(mockFetchResponse) const azureResult = await azureEmbedder.createEmbeddings(testTexts) expect(global.fetch).toHaveBeenCalledWith( @@ -818,51 +719,23 @@ describe("OpenAICompatibleEmbedder", () => { it.each([ [401, "Authentication failed. Please check your API key."], - [500, "Failed to create embeddings after 3 attempts"], + [500, "Failed to create embeddings"], ])("should handle HTTP errors: %d", async (status, expectedMessage) => { const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) const mockResponse = createMockResponse({}, status, false) - ;(global.fetch as MockedFunction).mockResolvedValue(mockResponse as any) + ;(global.fetch as MockedFunction).mockResolvedValue(mockResponse) await expect(embedder.createEmbeddings(["test"])).rejects.toThrow(expectedMessage) }) - it("should handle rate limiting with retries", async () => { - vitest.useFakeTimers() + it("returns direct-fetch rate limits without retrying", async () => { const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) - const base64String = createBase64Embedding([0.1, 0.2, 0.3]) - - ;(global.fetch as MockedFunction) - .mockResolvedValueOnce(createMockResponse({}, 429, false) as any) - .mockResolvedValueOnce(createMockResponse({}, 429, false) as any) - .mockResolvedValueOnce( - createMockResponse({ - data: [{ embedding: base64String }], - usage: { prompt_tokens: 10, total_tokens: 15 }, - }) as any, - ) - - const resultPromise = embedder.createEmbeddings(["test"]) - - // First attempt fails, triggering global rate limit - await vitest.advanceTimersByTimeAsync(100) - - // Wait for global rate limit (5s) - await vitest.advanceTimersByTimeAsync(5000) - - // Second attempt also fails - await vitest.advanceTimersByTimeAsync(100) - - // Wait for increased global rate limit (10s) - await vitest.advanceTimersByTimeAsync(10000) - - const result = await resultPromise - - expect(global.fetch).toHaveBeenCalledTimes(3) - // Check that rate limit warnings were logged - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limit hit")) - expectEmbeddingValues(result.embeddings[0], [0.1, 0.2, 0.3]) - vitest.useRealTimers() + vi.mocked(global.fetch).mockResolvedValue(new Response("rate limited", { status: 429 })) + await expect(embedder.createEmbeddings(["test"])).rejects.toMatchObject({ + classification: "rate-limit", + statusCode: 429, + }) + expect(global.fetch).toHaveBeenCalledTimes(1) }) it("should handle multiple embeddings and network errors", async () => { @@ -875,7 +748,7 @@ describe("OpenAICompatibleEmbedder", () => { data: [{ embedding: base64_1 }, { embedding: base64_2 }], usage: { prompt_tokens: 20, total_tokens: 30 }, }) - ;(global.fetch as MockedFunction).mockResolvedValue(mockResponse as any) + ;(global.fetch as MockedFunction).mockResolvedValue(mockResponse) const result = await embedder.createEmbeddings(["test1", "test2"]) expect(result.embeddings).toHaveLength(2) @@ -885,9 +758,7 @@ describe("OpenAICompatibleEmbedder", () => { // Test network error const networkError = new Error("Network failed") ;(global.fetch as MockedFunction).mockRejectedValue(networkError) - await expect(embedder.createEmbeddings(["test"])).rejects.toThrow( - "Failed to create embeddings after 3 attempts", - ) + await expect(embedder.createEmbeddings(["test"])).rejects.toThrow("Failed to create embeddings") }) }) }) @@ -946,9 +817,10 @@ describe("OpenAICompatibleEmbedder", () => { data: [{ embedding: [0.1, 0.2] }], usage: { prompt_tokens: 10, total_tokens: 15 }, }) + // Only embeddings.create is used; the SDK resource's private client is not needed. embedder["embeddingsClient"].embeddings = { create: mockCreate, - } as any + } as unknown as OpenAI.Embeddings await embedder.createEmbeddings(["test"]) @@ -1000,7 +872,7 @@ describe("OpenAICompatibleEmbedder", () => { usage: { prompt_tokens: 2, total_tokens: 2 }, }), text: async () => "", - } as any) + } as Response) const result = await embedder.validateConfiguration() @@ -1021,7 +893,7 @@ describe("OpenAICompatibleEmbedder", () => { embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) const authError = new Error("Invalid API key") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) const result = await embedder.validateConfiguration() @@ -1051,7 +923,7 @@ describe("OpenAICompatibleEmbedder", () => { status: 404, json: async () => ({ error: "Not found" }), text: async () => "Not found", - } as any) + } as Response) const result = await embedder.validateConfiguration() @@ -1063,20 +935,19 @@ describe("OpenAICompatibleEmbedder", () => { embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) const rateLimitError = new Error("Rate limit exceeded") - ;(rateLimitError as any).status = 429 + Object.assign(rateLimitError, { status: 429 }) mockEmbeddingsCreate.mockRejectedValue(rateLimitError) const result = await embedder.validateConfiguration() - expect(result.valid).toBe(false) - expect(result.error).toBe("embeddings:validation.serviceUnavailable") + expect(result).toEqual({ valid: false, error: "embeddings:validation.serviceUnavailable" }) }) it("should fail validation with generic error", async () => { embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) const genericError = new Error("Unknown error") - ;(genericError as any).status = 500 + Object.assign(genericError, { status: 500 }) mockEmbeddingsCreate.mockRejectedValue(genericError) const result = await embedder.validateConfiguration() diff --git a/src/services/code-index/embedders/__tests__/openai.spec.ts b/src/services/code-index/embedders/__tests__/openai.spec.ts index 4ad8239b53..c0b7f7e0cb 100644 --- a/src/services/code-index/embedders/__tests__/openai.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai.spec.ts @@ -1,8 +1,8 @@ -import type { MockedClass, MockedFunction } from "vitest" +import type { MockedClass } from "vitest" import { OpenAI } from "openai" import { OpenAiEmbedder } from "../openai" -import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { MAX_ITEM_TOKENS } from "../../constants" import { clearAllMocks } from "../../../../test-utils/reset" // Mock the OpenAI SDK @@ -19,13 +19,13 @@ vitest.mock("@roo-code/telemetry", () => ({ // Mock i18n vitest.mock("../../../../i18n", () => ({ - t: (key: string, params?: Record) => { + t: (key: string, params?: Record) => { const translations: Record = { "embeddings:authenticationFailed": "Failed to create embeddings: Authentication failed. Please check your OpenAI API key.", - "embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`, - "embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`, - "embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`, + "embeddings:failedWithStatus": `Failed to create embeddings: HTTP ${params?.statusCode} - ${params?.errorMessage}`, + "embeddings:failedWithError": `Failed to create embeddings: ${params?.errorMessage}`, + "embeddings:failedMaxAttempts": `Failed to create embeddings`, "embeddings:textExceedsTokenLimit": `Text at index ${params?.index} exceeds maximum token limit (${params?.itemTokens} > ${params?.maxTokens}). Skipping.`, "embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`, } @@ -41,7 +41,7 @@ const consoleMocks = { describe("OpenAiEmbedder", () => { let embedder: OpenAiEmbedder - let mockEmbeddingsCreate: MockedFunction + let mockEmbeddingsCreate: ReturnType Promise>> let MockedOpenAI: MockedClass beforeEach(() => { @@ -52,9 +52,10 @@ describe("OpenAiEmbedder", () => { MockedOpenAI = OpenAI as MockedClass mockEmbeddingsCreate = vitest.fn() + // Only embeddings.create is used; the SDK resource's private client is not needed. MockedOpenAI.prototype.embeddings = { create: mockEmbeddingsCreate, - } as any + } as unknown as OpenAI.Embeddings embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-api-key", @@ -68,7 +69,7 @@ describe("OpenAiEmbedder", () => { describe("constructor", () => { it("should initialize with provided options", () => { - expect(MockedOpenAI).toHaveBeenCalledWith({ apiKey: "test-api-key" }) + expect(MockedOpenAI).toHaveBeenCalledWith({ apiKey: "test-api-key", maxRetries: 0 }) expect(embedder.embedderInfo.name).toBe("openai") }) @@ -77,7 +78,7 @@ describe("OpenAiEmbedder", () => { openAiEmbeddingModelId: "text-embedding-3-small", }) - expect(MockedOpenAI).toHaveBeenCalledWith({ apiKey: "not-provided" }) + expect(MockedOpenAI).toHaveBeenCalledWith({ apiKey: "not-provided", maxRetries: 0 }) }) it("should use default model if not specified", () => { @@ -274,38 +275,22 @@ describe("OpenAiEmbedder", () => { vitest.useRealTimers() }) - it("should retry on rate limit errors with exponential backoff", async () => { - const testTexts = ["Hello world"] - const rateLimitError = { status: 429, message: "Rate limit exceeded" } - - mockEmbeddingsCreate - .mockRejectedValueOnce(rateLimitError) - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValueOnce({ - data: [{ embedding: [0.1, 0.2, 0.3] }], - usage: { prompt_tokens: 10, total_tokens: 15 }, - }) - - const resultPromise = embedder.createEmbeddings(testTexts) - - // Fast-forward through the delays - await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS) // First retry delay - await vitest.advanceTimersByTimeAsync(INITIAL_RETRY_DELAY_MS * 2) // Second retry delay - - const result = await resultPromise - - expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limit hit, retrying in")) - expect(result).toEqual({ - embeddings: [[0.1, 0.2, 0.3]], - usage: { promptTokens: 10, totalTokens: 15 }, + it("returns rate limits without internal retries", async () => { + const cause = Object.assign(new Error("rate limited"), { status: 429, name: "ThrottlingException" }) + mockEmbeddingsCreate.mockRejectedValue(cause) + await expect(embedder.createEmbeddings(["test"])).rejects.toMatchObject({ + classification: "rate-limit", + statusCode: 429, + cause, }) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) }) it("should not retry on non-rate-limit errors", async () => { const testTexts = ["Hello world"] const authError = new Error("Unauthorized") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) @@ -320,12 +305,12 @@ describe("OpenAiEmbedder", () => { it("should throw error immediately on non-retryable errors", async () => { const testTexts = ["Hello world"] const serverError = new Error("Internal server error") - ;(serverError as any).status = 500 + Object.assign(serverError, { status: 500 }) mockEmbeddingsCreate.mockRejectedValue(serverError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: HTTP 500 - Internal server error", + "Failed to create embeddings: HTTP 500 - Internal server error", ) expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) @@ -343,7 +328,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(apiError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: API connection failed", + "Failed to create embeddings: API connection failed", ) expect(console.error).toHaveBeenCalledWith( @@ -379,7 +364,7 @@ describe("OpenAiEmbedder", () => { it("should provide specific authentication error message", async () => { const testTexts = ["Hello world"] const authError = new Error("Invalid API key") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) @@ -391,12 +376,12 @@ describe("OpenAiEmbedder", () => { it("should provide detailed error message for HTTP errors", async () => { const testTexts = ["Hello world"] const httpError = new Error("Bad request") - ;(httpError as any).status = 400 + Object.assign(httpError, { status: 400 }) mockEmbeddingsCreate.mockRejectedValue(httpError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: HTTP 400 - Bad request", + "Failed to create embeddings: HTTP 400 - Bad request", ) }) @@ -407,7 +392,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(networkError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Network timeout", + "Failed to create embeddings: Network timeout", ) }) @@ -418,7 +403,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(weirdError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Custom error object", + "Failed to create embeddings: Custom error object", ) }) @@ -429,7 +414,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(unknownError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Unknown error", + "Failed to create embeddings: Unknown error", ) }) @@ -440,7 +425,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(stringError) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Something went wrong", + "Failed to create embeddings: Something went wrong", ) }) @@ -472,7 +457,7 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(errorWithResponseStatus) await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: HTTP 403 - Request failed", + "Failed to create embeddings: HTTP 403 - Request failed", ) }) }) @@ -498,7 +483,7 @@ describe("OpenAiEmbedder", () => { it("should fail validation with authentication error", async () => { const authError = new Error("Invalid API key") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) const result = await embedder.validateConfiguration() @@ -509,13 +494,12 @@ describe("OpenAiEmbedder", () => { it("should fail validation with rate limit error", async () => { const rateLimitError = new Error("Rate limit exceeded") - ;(rateLimitError as any).status = 429 + Object.assign(rateLimitError, { status: 429 }) mockEmbeddingsCreate.mockRejectedValue(rateLimitError) const result = await embedder.validateConfiguration() - expect(result.valid).toBe(false) - expect(result.error).toBe("embeddings:validation.serviceUnavailable") + expect(result).toEqual({ valid: false, error: "embeddings:validation.serviceUnavailable" }) }) it("should fail validation with connection error", async () => { @@ -530,7 +514,7 @@ describe("OpenAiEmbedder", () => { it("should fail validation with generic error", async () => { const genericError = new Error("Unknown error") - ;(genericError as any).status = 500 + Object.assign(genericError, { status: 500 }) mockEmbeddingsCreate.mockRejectedValue(genericError) const result = await embedder.validateConfiguration() diff --git a/src/services/code-index/embedders/__tests__/openrouter.spec.ts b/src/services/code-index/embedders/__tests__/openrouter.spec.ts index d782ecd9cb..14ddfa6005 100644 --- a/src/services/code-index/embedders/__tests__/openrouter.spec.ts +++ b/src/services/code-index/embedders/__tests__/openrouter.spec.ts @@ -1,4 +1,4 @@ -import type { MockedClass, MockedFunction } from "vitest" +import type { MockedClass } from "vitest" import { describe, it, expect, beforeEach, vi } from "vitest" import { OpenAI } from "openai" import { OpenRouterEmbedder, OPENROUTER_DEFAULT_PROVIDER_NAME } from "../openrouter" @@ -21,14 +21,14 @@ vi.mock("@roo-code/telemetry", () => ({ // Mock i18n vi.mock("../../../../i18n", () => ({ - t: (key: string, params?: Record) => { + t: (key: string, params?: Record) => { const translations: Record = { "embeddings:validation.apiKeyRequired": "validation.apiKeyRequired", "embeddings:authenticationFailed": "Failed to create embeddings: Authentication failed. Please check your OpenRouter API key.", - "embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`, - "embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`, - "embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`, + "embeddings:failedWithStatus": `Failed to create embeddings: HTTP ${params?.statusCode} - ${params?.errorMessage}`, + "embeddings:failedWithError": `Failed to create embeddings: ${params?.errorMessage}`, + "embeddings:failedMaxAttempts": `Failed to create embeddings`, "embeddings:textExceedsTokenLimit": `Text at index ${params?.index} exceeds maximum token limit (${params?.itemTokens} > ${params?.maxTokens}). Skipping.`, "embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`, } @@ -40,8 +40,8 @@ const MockedOpenAI = OpenAI as MockedClass describe("OpenRouterEmbedder", () => { const mockApiKey = "test-api-key" - let mockEmbeddingsCreate: MockedFunction - let mockOpenAIInstance: any + let mockEmbeddingsCreate: ReturnType Promise>> + let mockOpenAIInstance: { embeddings: { create: typeof mockEmbeddingsCreate } } beforeEach(() => { clearAllMocks() @@ -57,7 +57,8 @@ describe("OpenRouterEmbedder", () => { } MockedOpenAI.mockImplementation(function () { - return mockOpenAIInstance + // The embedder only uses embeddings.create on this client double. + return mockOpenAIInstance as unknown as OpenAI }) }) @@ -92,6 +93,7 @@ describe("OpenRouterEmbedder", () => { expect(MockedOpenAI).toHaveBeenCalledWith({ baseURL: "https://openrouter.ai/api/v1", apiKey: mockApiKey, + maxRetries: 0, defaultHeaders: { "HTTP-Referer": "https://github.com/Zoo-Code-Org/Zoo-Code", "X-Title": "Zoo Code", @@ -329,7 +331,7 @@ describe("OpenRouterEmbedder", () => { it("should handle validation failure", async () => { const authError = new Error("Invalid API key") - ;(authError as any).status = 401 + Object.assign(authError, { status: 401 }) mockEmbeddingsCreate.mockRejectedValue(authError) @@ -377,6 +379,19 @@ describe("OpenRouterEmbedder", () => { }) }) + describe("CI-EM-01: single attempt with classified errors", () => { + it.each([ + [503, {}, "server", undefined], + [429, { "retry-after": "7" }, "rate-limit", 7000], + ])("sends one request on HTTP %s and classifies it", async (status, headers, classification, delay) => { + mockEmbeddingsCreate.mockRejectedValue(Object.assign(new Error("failed"), { status, headers })) + const embedder = new OpenRouterEmbedder(mockApiKey) + const error = await embedder.createEmbeddings(["text"]).catch((caught: unknown) => caught) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + expect(error).toMatchObject({ classification, statusCode: status, retryAfterMs: delay }) + }) + }) + describe("integration with shared models", () => { it("should work with defined OpenRouter models", () => { const openRouterModels = [ diff --git a/src/services/code-index/embedders/bedrock.ts b/src/services/code-index/embedders/bedrock.ts index 0e153f3c2c..10824172fa 100644 --- a/src/services/code-index/embedders/bedrock.ts +++ b/src/services/code-index/embedders/bedrock.ts @@ -2,16 +2,14 @@ import { BedrockRuntimeClient, InvokeModelCommand, InvokeModelCommandInput } fro import { fromIni, fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createProxyRoutingRequestHandler } from "../../../utils/networkProxy" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces" -import { - MAX_BATCH_TOKENS, - MAX_ITEM_TOKENS, - MAX_BATCH_RETRIES as MAX_RETRIES, - INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, -} from "../constants" +import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS } from "../constants" import { getDefaultModelId } from "../../../shared/embeddingModels" import { Package } from "../../../shared/package" import { t } from "../../../i18n" -import { withValidationErrorHandling, formatEmbeddingError } from "../shared/validation-helpers" +import { withValidationErrorHandling, toEmbeddingError } from "../shared/validation-helpers" +import { EmbeddingError } from "../shared/indexing-errors" +import { retryWithPolicy } from "../shared/retry-policy" +import { makeRateLimitScope, type ProviderPacerRegistry } from "../provider-pacer" import { TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -21,17 +19,20 @@ import { TelemetryService } from "@roo-code/telemetry" export class BedrockEmbedder implements IEmbedder { private bedrockClient: BedrockRuntimeClient private readonly defaultModelId: string + readonly rateLimitScope: string /** * Creates a new Amazon Bedrock embedder * @param region AWS region for Bedrock service (required) * @param profile AWS profile name for credentials (optional - uses default credential chain if not provided) * @param modelId Optional model ID override + * @param pacers Shared rate-limit waits; validation requests honor the same pause as indexing */ constructor( private readonly region: string, - private readonly profile?: string, - modelId?: string, + private readonly profile: string | undefined, + modelId: string | undefined, + private readonly pacers: ProviderPacerRegistry, ) { if (!region) { throw new Error("Region is required for AWS Bedrock embedder") @@ -50,10 +51,13 @@ export class BedrockEmbedder implements IEmbedder { userAgentAppId: `ZooCode#${Package.version}`, region: this.region, credentials, + // Retries are owned by the indexing retry policy; SDK retries would multiply its allowance. + maxAttempts: 1, ...(requestHandler && { requestHandler }), }) this.defaultModelId = modelId || getDefaultModelId("bedrock") + this.rateLimitScope = makeRateLimitScope("bedrock", undefined, undefined, `${region}|${profile ?? ""}`) } /** @@ -105,7 +109,7 @@ export class BedrockEmbedder implements IEmbedder { } if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + const batchResult = await this._embedBatch(currentBatch, modelToUse) allEmbeddings.push(...batchResult.embeddings) usage.promptTokens += batchResult.usage.promptTokens usage.totalTokens += batchResult.usage.totalTokens @@ -115,73 +119,32 @@ export class BedrockEmbedder implements IEmbedder { return { embeddings: allEmbeddings, usage } } - /** - * Helper method to handle batch embedding with retries and exponential backoff - * @param batchTexts Array of texts to embed in this batch - * @param model Model identifier to use - * @returns Promise resolving to embeddings and usage statistics - */ - private async _embedBatchWithRetries( + /** Sends one request per text; retries and rate-limit waits belong to the caller's retry policy and pacer. */ + private async _embedBatch( batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { - try { - const embeddings: number[][] = [] - let totalPromptTokens = 0 - let totalTokens = 0 - - // Process each text in the batch - // Note: Amazon Titan models typically don't support batch embedding in a single request - // So we process them individually - for (const text of batchTexts) { - const embedding = await this._invokeEmbeddingModel(text, model) - embeddings.push(embedding.embedding) - totalPromptTokens += embedding.inputTextTokenCount || 0 - totalTokens += embedding.inputTextTokenCount || 0 - } - - return { - embeddings, - usage: { - promptTokens: totalPromptTokens, - totalTokens, - }, - } - } catch (error: any) { - const hasMoreAttempts = attempts < MAX_RETRIES - 1 - - // Check if it's a rate limit error - if (error.name === "ThrottlingException" && hasMoreAttempts) { - const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts) - console.warn( - t("embeddings:rateLimitRetry", { - delayMs, - attempt: attempts + 1, - maxRetries: MAX_RETRIES, - }), - ) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - continue - } - - // Capture telemetry before reformatting the error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "BedrockEmbedder:_embedBatchWithRetries", - attempt: attempts + 1, - }) - - // Log the error for debugging - console.error(`Bedrock embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) - - // Format and throw the error - throw formatEmbeddingError(error, MAX_RETRIES) + try { + const embeddings: number[][] = [] + let totalTokens = 0 + + // Amazon Titan models typically don't support batch embedding in a single request. + for (const text of batchTexts) { + const embedding = await this._invokeEmbeddingModel(text, model) + embeddings.push(embedding.embedding) + totalTokens += embedding.inputTextTokenCount || 0 } - } - throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES })) + return { embeddings, usage: { promptTokens: totalTokens, totalTokens } } + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "BedrockEmbedder:_embedBatch", + }) + console.error("Bedrock embedder error:", error) + throw toEmbeddingError(asRateLimit(error)) + } } /** @@ -289,8 +252,18 @@ export class BedrockEmbedder implements IEmbedder { async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { return withValidationErrorHandling(async () => { try { - // Test with a minimal embedding request - const result = await this._invokeEmbeddingModel("test", this.defaultModelId) + // Temporary server, connection and throttling failures recover instead of failing validation. + const result = await retryWithPolicy( + () => + this.pacers.get(this.rateLimitScope).run(undefined, undefined, async () => { + try { + return await this._invokeEmbeddingModel("test", this.defaultModelId) + } catch (error) { + throw asRateLimit(error) + } + }), + { budget: { retriesUsed: 0 } }, + ) // Check if we got a valid response if (!result.embedding || result.embedding.length === 0) { @@ -301,7 +274,8 @@ export class BedrockEmbedder implements IEmbedder { } return { valid: true } - } catch (error: any) { + } catch (caught: any) { + const error = caught?.cause ?? caught // Check for specific AWS errors if (error.name === "UnrecognizedClientException") { return { @@ -341,3 +315,11 @@ export class BedrockEmbedder implements IEmbedder { } } } + +// The AWS SDK reports throttling by name; give it a 429 so the shared classifier treats it as a rate limit. +function asRateLimit(error: unknown): unknown { + if (error instanceof Error && error.name === "ThrottlingException") { + return new EmbeddingError(error.message, error, { classification: "rate-limit", statusCode: 429 }) + } + return error +} diff --git a/src/services/code-index/embedders/cancellation.ts b/src/services/code-index/embedders/cancellation.ts new file mode 100644 index 0000000000..a5e0d61d9b --- /dev/null +++ b/src/services/code-index/embedders/cancellation.ts @@ -0,0 +1,15 @@ +export function waitForRetry(delayMs: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeout) + signal?.removeEventListener("abort", onAbort) + reject(signal?.reason) + } + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, delayMs) + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} diff --git a/src/services/code-index/embedders/embedder-factory.ts b/src/services/code-index/embedders/embedder-factory.ts index 31d6e8a754..af6812aa59 100644 --- a/src/services/code-index/embedders/embedder-factory.ts +++ b/src/services/code-index/embedders/embedder-factory.ts @@ -4,6 +4,7 @@ import { t } from "../../../i18n" import type { CodeIndexConfig } from "../interfaces/config" import type { IEmbedder } from "../interfaces" import type { IEmbedderFactory } from "../interfaces/embedder-factory" +import type { ProviderPacerRegistry } from "../provider-pacer" import { OpenAiEmbedderFactory } from "./factories/openai-embedder-factory" import { OllamaEmbedderFactory } from "./factories/ollama-embedder-factory" import { OpenAICompatibleEmbedderFactory } from "./factories/openai-compatible-embedder-factory" @@ -31,7 +32,7 @@ export class EmbedderFactory implements IEmbedderFactory { [providerIdentifiers.openrouter]: new OpenRouterEmbedderFactory(), } - public create(config: CodeIndexConfig): IEmbedder { + public create(config: CodeIndexConfig, pacers: ProviderPacerRegistry): IEmbedder { const provider = config.embedderProvider // Semble is a self-contained search binary, not a vector embedder. @@ -46,6 +47,6 @@ export class EmbedderFactory implements IEmbedderFactory { throw new Error(t("embeddings:serviceFactory.invalidEmbedderType", { embedderProvider: provider })) } - return this.embedderFactories[provider].create(config) + return this.embedderFactories[provider].create(config, pacers) } } diff --git a/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts index 5a93c5619b..5992fe9a46 100644 --- a/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts +++ b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts @@ -1,14 +1,16 @@ import type { CodeIndexConfig } from "../../interfaces/config" import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import type { ProviderPacerRegistry } from "../../provider-pacer" import { BedrockEmbedder } from "../bedrock" import { requireSetting } from "./require-setting" export class BedrockEmbedderFactory implements IEmbedderFactory { - create({ bedrockOptions, modelId }: CodeIndexConfig): BedrockEmbedder { + create({ bedrockOptions, modelId }: CodeIndexConfig, pacers: ProviderPacerRegistry): BedrockEmbedder { return new BedrockEmbedder( requireSetting(bedrockOptions?.region, "embeddings:serviceFactory.bedrockConfigMissing"), bedrockOptions?.profile, modelId, + pacers, ) } } diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index 03bfc35aae..e09e37f2ff 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -28,6 +28,10 @@ export class GeminiEmbedder implements IEmbedder { } private readonly modelId: string + get rateLimitScope(): string { + return this.openAICompatibleEmbedder.rateLimitScope + } + /** * Migrates deprecated model IDs to their replacements. * @param modelId The model ID to potentially migrate diff --git a/src/services/code-index/embedders/mistral.ts b/src/services/code-index/embedders/mistral.ts index c23bcbba1d..a6895d3b50 100644 --- a/src/services/code-index/embedders/mistral.ts +++ b/src/services/code-index/embedders/mistral.ts @@ -18,6 +18,10 @@ export class MistralEmbedder implements IEmbedder { private static readonly DEFAULT_MODEL = "codestral-embed-2505" private readonly modelId: string + get rateLimitScope(): string { + return this.openAICompatibleEmbedder.rateLimitScope + } + /** * Creates a new Mistral embedder * @param apiKey The Mistral API key for authentication diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index 654c41240f..c75ce632b8 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -4,6 +4,8 @@ import { getModelQueryPrefix } from "../../../shared/embeddingModels" import { MAX_ITEM_TOKENS } from "../constants" import { t } from "../../../i18n" import { withValidationErrorHandling, sanitizeErrorMessage } from "../shared/validation-helpers" +import { EmbeddingError } from "../shared/indexing-errors" +import { makeRateLimitScope } from "../provider-pacer" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" @@ -17,6 +19,7 @@ const OLLAMA_VALIDATION_TIMEOUT_MS = 30000 // 30 seconds for validation requests export class CodeIndexOllamaEmbedder implements IEmbedder { private readonly baseUrl: string private readonly defaultModelId: string + readonly rateLimitScope: string constructor(options: ApiHandlerOptions) { // Ensure ollamaBaseUrl and ollamaModelId exist on ApiHandlerOptions or add defaults @@ -27,6 +30,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { this.baseUrl = baseUrl this.defaultModelId = options.ollamaModelId || "nomic-embed-text:latest" + this.rateLimitScope = makeRateLimitScope("ollama", baseUrl) } /** @@ -92,13 +96,13 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { } catch { // Ignore error reading body } - throw new Error( - t("embeddings:ollama.requestFailed", { - status: response.status, - statusText: response.statusText, - errorBody, - }), - ) + // Keep status and headers so a 5xx or 429 is retried or paced instead of failing at once. + const message = t("embeddings:ollama.requestFailed", { + status: response.status, + statusText: response.statusText, + errorBody, + }) + throw new EmbeddingError(message, { status: response.status, headers: response.headers }) } const data = await response.json() @@ -113,6 +117,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { embeddings: embeddings, } } catch (error: any) { + if (error instanceof EmbeddingError) throw error // Capture telemetry before reformatting the error TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index 5cbbf67efc..b0be6ef8ab 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -1,18 +1,13 @@ import { OpenAI } from "openai" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" -import { - MAX_BATCH_TOKENS, - MAX_ITEM_TOKENS, - MAX_BATCH_RETRIES as MAX_RETRIES, - INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, -} from "../constants" +import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" -import { withValidationErrorHandling, HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { withValidationErrorHandling, HttpError, toEmbeddingError } from "../shared/validation-helpers" import { TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Mutex } from "async-mutex" import { handleOpenAIError } from "../../../api/providers/utils/error-handler" +import { makeRateLimitScope } from "../provider-pacer" interface EmbeddingItem { embedding: string | number[] @@ -39,16 +34,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { private readonly apiKey: string private readonly isFullUrl: boolean private readonly maxItemTokens: number - - // Global rate limiting state shared across all instances - private static globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - // Mutex to ensure thread-safe access to rate limit state - mutex: new Mutex(), - } + readonly rateLimitScope: string /** * Creates a new OpenAI Compatible embedder @@ -70,9 +56,11 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Wrap OpenAI client creation to handle invalid API key characters try { + // Retries are owned by the indexing retry policy; SDK retries would multiply its allowance. this.embeddingsClient = new OpenAI({ baseURL: baseUrl, apiKey: apiKey, + maxRetries: 0, }) } catch (error) { // Use the error handler to transform ByteString conversion errors @@ -83,6 +71,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Cache the URL type check for performance this.isFullUrl = this.isFullEndpointUrl(baseUrl) this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + this.rateLimitScope = makeRateLimitScope("openai-compatible", baseUrl, apiKey) } /** @@ -159,7 +148,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder { } if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + const batchResult = await this._embedBatch(currentBatch, modelToUse) allEmbeddings.push(...batchResult.embeddings) usage.promptTokens += batchResult.usage.promptTokens usage.totalTokens += batchResult.usage.totalTokens @@ -233,9 +222,11 @@ export class OpenAICompatibleEmbedder implements IEmbedder { // Ignore text parsing errors errorText = `Error ${status}` } - const error = new Error(`HTTP ${status}: ${errorText}`) as HttpError - error.status = status || response?.status || 0 - throw error + // Headers carry Retry-After for the shared rate-limit wait. + throw Object.assign(new Error(`HTTP ${status}: ${errorText}`), { + status: status || response?.status || 0, + headers: response?.headers, + }) } try { @@ -248,112 +239,60 @@ export class OpenAICompatibleEmbedder implements IEmbedder { } /** - * Helper method to handle batch embedding with retries and exponential backoff - * @param batchTexts Array of texts to embed in this batch - * @param model Model identifier to use - * @returns Promise resolving to embeddings and usage statistics - */ - private async _embedBatchWithRetries( + /** Sends one request; retries and rate-limit waits belong to the caller's retry policy and pacer. */ + private async _embedBatch( batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - // Use cached value for performance - const isFullUrl = this.isFullUrl - - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { - // Check global rate limit before attempting request - await this.waitForGlobalRateLimit() - - try { - let response: OpenAIEmbeddingResponse - - if (isFullUrl) { - // Use direct HTTP request for full endpoint URLs - response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) - } else { - // Use OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ + try { + const response: OpenAIEmbeddingResponse = this.isFullUrl + ? await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) + : ((await this.embeddingsClient.embeddings.create({ input: batchTexts, model: model, // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 // when processing numeric arrays, which breaks compatibility with models using larger dimensions. // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. encoding_format: "base64", - })) as OpenAIEmbeddingResponse - } + })) as OpenAIEmbeddingResponse) - // Convert base64 embeddings to float32 arrays - const processedEmbeddings = response.data.map((item: EmbeddingItem) => { - if (typeof item.embedding === "string") { - const buffer = Buffer.from(item.embedding, "base64") + // Convert base64 embeddings to float32 arrays + const processedEmbeddings = response.data.map((item: EmbeddingItem) => { + if (typeof item.embedding === "string") { + const buffer = Buffer.from(item.embedding, "base64") - // Create Float32Array view over the buffer - const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + // Create Float32Array view over the buffer + const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) - return { - ...item, - embedding: Array.from(float32Array), - } + return { + ...item, + embedding: Array.from(float32Array), } - return item - }) - - // Replace the original data with processed embeddings - response.data = processedEmbeddings - - const embeddings = response.data.map((item) => item.embedding as number[]) - - return { - embeddings: embeddings, - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - totalTokens: response.usage?.total_tokens || 0, - }, } - } catch (error) { - // Capture telemetry before error is reformatted - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "OpenAICompatibleEmbedder:_embedBatchWithRetries", - attempt: attempts + 1, - }) - - const hasMoreAttempts = attempts < MAX_RETRIES - 1 - - // Check if it's a rate limit error - const httpError = error as HttpError - if (httpError?.status === 429) { - // Update global rate limit state - await this.updateGlobalRateLimitState(httpError) - - if (hasMoreAttempts) { - // Calculate delay based on global rate limit state - const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts) - const globalDelay = await this.getGlobalRateLimitDelay() - const delayMs = Math.max(baseDelay, globalDelay) + return item + }) - console.warn( - t("embeddings:rateLimitRetry", { - delayMs, - attempt: attempts + 1, - maxRetries: MAX_RETRIES, - }), - ) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - continue - } - } + // Replace the original data with processed embeddings + response.data = processedEmbeddings - // Log the error for debugging - console.error(`OpenAI Compatible embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) + const embeddings = response.data.map((item) => item.embedding as number[]) - // Format and throw the error - throw formatEmbeddingError(error, MAX_RETRIES) + return { + embeddings: embeddings, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, } + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAICompatibleEmbedder:_embedBatch", + }) + console.error("OpenAI Compatible embedder error:", error) + throw toEmbeddingError(error) } - - throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES })) } /** @@ -410,87 +349,4 @@ export class OpenAICompatibleEmbedder implements IEmbedder { name: "openai-compatible", } } - - /** - * Waits if there's an active global rate limit - */ - private async waitForGlobalRateLimit(): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - const waitTime = state.rateLimitResetTime - Date.now() - // Silent wait - no logging to prevent flooding - release() // Release mutex before waiting - await new Promise((resolve) => setTimeout(resolve, waitTime)) - return - } - - // Reset rate limit if time has passed - if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { - state.isRateLimited = false - state.consecutiveRateLimitErrors = 0 - } - } finally { - // Only release if we haven't already - try { - release() - } catch { - // Already released - } - } - } - - /** - * Updates global rate limit state when a 429 error occurs - */ - private async updateGlobalRateLimitState(_error: HttpError): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - const now = Date.now() - - // Increment consecutive rate limit errors - if (now - state.lastRateLimitError < 60000) { - // Within 1 minute - state.consecutiveRateLimitErrors++ - } else { - state.consecutiveRateLimitErrors = 1 - } - - state.lastRateLimitError = now - - // Calculate exponential backoff based on consecutive errors - const baseDelay = 5000 // 5 seconds base - const maxDelay = 300000 // 5 minutes max - const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) - - // Set global rate limit - state.isRateLimited = true - state.rateLimitResetTime = now + exponentialDelay - - // Silent rate limit activation - no logging to prevent flooding - } finally { - release() - } - } - - /** - * Gets the current global rate limit delay - */ - private async getGlobalRateLimitDelay(): Promise { - const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenAICompatibleEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - return state.rateLimitResetTime - Date.now() - } - - return 0 - } finally { - release() - } - } } diff --git a/src/services/code-index/embedders/openai.ts b/src/services/code-index/embedders/openai.ts index 4ff8f27e47..0138516def 100644 --- a/src/services/code-index/embedders/openai.ts +++ b/src/services/code-index/embedders/openai.ts @@ -2,15 +2,11 @@ import { OpenAI } from "openai" import { OpenAiNativeHandler } from "../../../api/providers/openai-native" import { ApiHandlerOptions } from "../../../shared/api" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces" -import { - MAX_BATCH_TOKENS, - MAX_ITEM_TOKENS, - MAX_BATCH_RETRIES as MAX_RETRIES, - INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, -} from "../constants" +import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS } from "../constants" import { getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" -import { withValidationErrorHandling, formatEmbeddingError, HttpError } from "../shared/validation-helpers" +import { withValidationErrorHandling, toEmbeddingError } from "../shared/validation-helpers" +import { makeRateLimitScope } from "../provider-pacer" import { TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { handleOpenAIError } from "../../../api/providers/utils/error-handler" @@ -21,6 +17,7 @@ import { handleOpenAIError } from "../../../api/providers/utils/error-handler" export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { private embeddingsClient: OpenAI private readonly defaultModelId: string + readonly rateLimitScope: string /** * Creates a new OpenAI embedder @@ -32,13 +29,15 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { // Wrap OpenAI client creation to handle invalid API key characters try { - this.embeddingsClient = new OpenAI({ apiKey }) + // Retries are owned by the indexing retry policy; SDK retries would multiply its allowance. + this.embeddingsClient = new OpenAI({ apiKey, maxRetries: 0 }) } catch (error) { // Use the error handler to transform ByteString conversion errors throw handleOpenAIError(error, "OpenAI") } this.defaultModelId = options.openAiEmbeddingModelId || "text-embedding-3-small" + this.rateLimitScope = makeRateLimitScope("openai", "https://api.openai.com/v1", apiKey) } /** @@ -115,7 +114,7 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { } if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + const batchResult = await this._embedBatch(currentBatch, modelToUse) allEmbeddings.push(...batchResult.embeddings) usage.promptTokens += batchResult.usage.promptTokens usage.totalTokens += batchResult.usage.totalTokens @@ -125,65 +124,33 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { return { embeddings: allEmbeddings, usage } } - /** - * Helper method to handle batch embedding with retries and exponential backoff - * @param batchTexts Array of texts to embed in this batch - * @param model Model identifier to use - * @returns Promise resolving to embeddings and usage statistics - */ - private async _embedBatchWithRetries( + /** Sends one request; retries and rate-limit waits belong to the caller's retry policy and pacer. */ + private async _embedBatch( batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { - try { - const response = await this.embeddingsClient.embeddings.create({ - input: batchTexts, - model: model, - }) - - return { - embeddings: response.data.map((item) => item.embedding), - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - totalTokens: response.usage?.total_tokens || 0, - }, - } - } catch (error: any) { - const hasMoreAttempts = attempts < MAX_RETRIES - 1 - - // Check if it's a rate limit error - const httpError = error as HttpError - if (httpError?.status === 429 && hasMoreAttempts) { - const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts) - console.warn( - t("embeddings:rateLimitRetry", { - delayMs, - attempt: attempts + 1, - maxRetries: MAX_RETRIES, - }), - ) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - continue - } - - // Capture telemetry before reformatting the error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "OpenAiEmbedder:_embedBatchWithRetries", - attempt: attempts + 1, - }) - - // Log the error for debugging - console.error(`OpenAI embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) - - // Format and throw the error - throw formatEmbeddingError(error, MAX_RETRIES) + try { + const response = await this.embeddingsClient.embeddings.create({ + input: batchTexts, + model: model, + }) + + return { + embeddings: response.data.map((item) => item.embedding), + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, } + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAiEmbedder:_embedBatch", + }) + console.error("OpenAI embedder error:", error) + throw toEmbeddingError(error) } - - throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES })) } /** diff --git a/src/services/code-index/embedders/openrouter.ts b/src/services/code-index/embedders/openrouter.ts index 258ccdb4eb..b0d484ea8c 100644 --- a/src/services/code-index/embedders/openrouter.ts +++ b/src/services/code-index/embedders/openrouter.ts @@ -1,18 +1,13 @@ import { OpenAI } from "openai" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" -import { - MAX_BATCH_TOKENS, - MAX_ITEM_TOKENS, - MAX_BATCH_RETRIES as MAX_RETRIES, - INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, -} from "../constants" +import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" -import { withValidationErrorHandling, HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { withValidationErrorHandling, toEmbeddingError } from "../shared/validation-helpers" import { TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Mutex } from "async-mutex" import { handleOpenAIError } from "../../../api/providers/utils/error-handler" +import { makeRateLimitScope } from "../provider-pacer" // Default provider name when no specific provider is selected export const OPENROUTER_DEFAULT_PROVIDER_NAME = "[default]" @@ -41,16 +36,7 @@ export class OpenRouterEmbedder implements IEmbedder { private readonly maxItemTokens: number private readonly baseUrl: string = "https://openrouter.ai/api/v1" private readonly specificProvider?: string - - // Global rate limiting state shared across all instances - private static globalRateLimitState = { - isRateLimited: false, - rateLimitResetTime: 0, - consecutiveRateLimitErrors: 0, - lastRateLimitError: 0, - // Mutex to ensure thread-safe access to rate limit state - mutex: new Mutex(), - } + readonly rateLimitScope: string /** * Creates a new OpenRouter embedder @@ -73,6 +59,8 @@ export class OpenRouterEmbedder implements IEmbedder { this.embeddingsClient = new OpenAI({ baseURL: this.baseUrl, apiKey: apiKey, + // Retries are owned by the indexing retry policy; SDK retries would multiply its allowance. + maxRetries: 0, defaultHeaders: { "HTTP-Referer": "https://github.com/Zoo-Code-Org/Zoo-Code", "X-Title": "Zoo Code", @@ -85,6 +73,7 @@ export class OpenRouterEmbedder implements IEmbedder { this.defaultModelId = modelId || getDefaultModelId("openrouter") this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + this.rateLimitScope = makeRateLimitScope("openrouter", this.baseUrl, apiKey) } /** @@ -161,7 +150,7 @@ export class OpenRouterEmbedder implements IEmbedder { } if (currentBatch.length > 0) { - const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + const batchResult = await this._embedBatch(currentBatch, modelToUse) allEmbeddings.push(...batchResult.embeddings) usage.promptTokens += batchResult.usage.promptTokens usage.totalTokens += batchResult.usage.totalTokens @@ -171,116 +160,70 @@ export class OpenRouterEmbedder implements IEmbedder { return { embeddings: allEmbeddings, usage } } - /** - * Helper method to handle batch embedding with retries and exponential backoff - * @param batchTexts Array of texts to embed in this batch - * @param model Model identifier to use - * @returns Promise resolving to embeddings and usage statistics - */ - private async _embedBatchWithRetries( + /** Sends one request; retries and rate-limit waits belong to the caller's retry policy and pacer. */ + private async _embedBatch( batchTexts: string[], model: string, ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { - for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { - // Check global rate limit before attempting request - await this.waitForGlobalRateLimit() - - try { - // Build the request parameters - const requestParams: any = { - input: batchTexts, - model: model, - // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 - // when processing numeric arrays, which breaks compatibility with models using larger dimensions. - // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. - encoding_format: "base64", - } - - // Add provider routing if a specific provider is set - if (this.specificProvider) { - requestParams.provider = { - order: [this.specificProvider], - only: [this.specificProvider], - allow_fallbacks: false, - } + try { + const requestParams: OpenAI.EmbeddingCreateParams & { provider?: unknown } = { + input: batchTexts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64", + } + if (this.specificProvider) { + requestParams.provider = { + order: [this.specificProvider], + only: [this.specificProvider], + allow_fallbacks: false, } + } - const response = (await this.embeddingsClient.embeddings.create( - requestParams, - )) as OpenRouterEmbeddingResponse + // The SDK types the response as float arrays, but base64 encoding returns strings, so the shapes do not overlap. + const response = (await this.embeddingsClient.embeddings.create( + requestParams, + )) as unknown as OpenRouterEmbeddingResponse - // Convert base64 embeddings to float32 arrays - const processedEmbeddings = response.data.map((item: EmbeddingItem) => { - if (typeof item.embedding === "string") { - const buffer = Buffer.from(item.embedding, "base64") + // Convert base64 embeddings to float32 arrays + const processedEmbeddings = response.data.map((item: EmbeddingItem) => { + if (typeof item.embedding === "string") { + const buffer = Buffer.from(item.embedding, "base64") - // Create Float32Array view over the buffer - const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + // Create Float32Array view over the buffer + const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) - return { - ...item, - embedding: Array.from(float32Array), - } + return { + ...item, + embedding: Array.from(float32Array), } - return item - }) - - // Replace the original data with processed embeddings - response.data = processedEmbeddings - - const embeddings = response.data.map((item) => item.embedding as number[]) - - return { - embeddings: embeddings, - usage: { - promptTokens: response.usage?.prompt_tokens || 0, - totalTokens: response.usage?.total_tokens || 0, - }, } - } catch (error) { - // Capture telemetry before error is reformatted - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "OpenRouterEmbedder:_embedBatchWithRetries", - attempt: attempts + 1, - }) - - const hasMoreAttempts = attempts < MAX_RETRIES - 1 - - // Check if it's a rate limit error - const httpError = error as HttpError - if (httpError?.status === 429) { - // Update global rate limit state - await this.updateGlobalRateLimitState(httpError) - - if (hasMoreAttempts) { - // Calculate delay based on global rate limit state - const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts) - const globalDelay = await this.getGlobalRateLimitDelay() - const delayMs = Math.max(baseDelay, globalDelay) + return item + }) - console.warn( - t("embeddings:rateLimitRetry", { - delayMs, - attempt: attempts + 1, - maxRetries: MAX_RETRIES, - }), - ) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - continue - } - } + // Replace the original data with processed embeddings + response.data = processedEmbeddings - // Log the error for debugging - console.error(`OpenRouter embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) + const embeddings = response.data.map((item) => item.embedding as number[]) - // Format and throw the error - throw formatEmbeddingError(error, MAX_RETRIES) + return { + embeddings, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, } + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenRouterEmbedder:_embedBatch", + }) + console.error("OpenRouter embedder error:", error) + throw toEmbeddingError(error) } - - throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES })) } /** @@ -343,88 +286,4 @@ export class OpenRouterEmbedder implements IEmbedder { name: "openrouter", } } - - /** - * Waits if there's an active global rate limit - */ - private async waitForGlobalRateLimit(): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - let mutexReleased = false - - try { - const state = OpenRouterEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - const waitTime = state.rateLimitResetTime - Date.now() - // Silent wait - no logging to prevent flooding - release() - mutexReleased = true - await new Promise((resolve) => setTimeout(resolve, waitTime)) - return - } - - // Reset rate limit if time has passed - if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { - state.isRateLimited = false - state.consecutiveRateLimitErrors = 0 - } - } finally { - // Only release if we haven't already - if (!mutexReleased) { - release() - } - } - } - - /** - * Updates global rate limit state when a 429 error occurs - */ - private async updateGlobalRateLimitState(_error: HttpError): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenRouterEmbedder.globalRateLimitState - const now = Date.now() - - // Increment consecutive rate limit errors - if (now - state.lastRateLimitError < 60000) { - // Within 1 minute - state.consecutiveRateLimitErrors++ - } else { - state.consecutiveRateLimitErrors = 1 - } - - state.lastRateLimitError = now - - // Calculate exponential backoff based on consecutive errors - const baseDelay = 5000 // 5 seconds base - const maxDelay = 300000 // 5 minutes max - const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) - - // Set global rate limit - state.isRateLimited = true - state.rateLimitResetTime = now + exponentialDelay - - // Silent rate limit activation - no logging to prevent flooding - } finally { - release() - } - } - - /** - * Gets the current global rate limit delay - */ - private async getGlobalRateLimitDelay(): Promise { - const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() - try { - const state = OpenRouterEmbedder.globalRateLimitState - - if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { - return state.rateLimitResetTime - Date.now() - } - - return 0 - } finally { - release() - } - } } diff --git a/src/services/code-index/embedders/vercel-ai-gateway.ts b/src/services/code-index/embedders/vercel-ai-gateway.ts index ec0888cd8b..715b7c2244 100644 --- a/src/services/code-index/embedders/vercel-ai-gateway.ts +++ b/src/services/code-index/embedders/vercel-ai-gateway.ts @@ -27,6 +27,10 @@ export class VercelAiGatewayEmbedder implements IEmbedder { private static readonly DEFAULT_MODEL = "openai/text-embedding-3-large" private readonly modelId: string + get rateLimitScope(): string { + return this.openAICompatibleEmbedder.rateLimitScope + } + /** * Creates a new Vercel AI Gateway embedder * @param apiKey The Vercel AI Gateway API key for authentication diff --git a/src/services/code-index/file-failure-registry.ts b/src/services/code-index/file-failure-registry.ts new file mode 100644 index 0000000000..3fc66785d6 --- /dev/null +++ b/src/services/code-index/file-failure-registry.ts @@ -0,0 +1,118 @@ +import type { Disposable, Event } from "vscode" +import type { IndexingErrorClassification } from "./shared/indexing-errors" +import type { RetryBudget, RetryFailure } from "./shared/retry-policy" + +export type FileFailureActivity = "read" | "parse" | "embed" | "upsert" | "delete" +export type FileFailureState = "retrying" | "exhausted" +export const REMOVAL_REVISION = "removed" +export const UNKNOWN_REVISION = "unread" + +export interface FailedFile { + path: string + revision: string + activity: FileFailureActivity + reason: string + classification: IndexingErrorClassification + attempt: number + state: FileFailureState + delayMs?: number + delaySource?: RetryFailure["delaySource"] +} + +type Revision = { + revision: string + controller: AbortController + failures: Map + budgets: Map +} + +export class FileFailureRegistry { + private readonly entries = new Map() + private readonly revisions = new Map() + private readonly changes = new Set<() => void>() + + readonly onDidChange: Event = (listener, thisArgument, disposables) => { + const callback = () => listener.call(thisArgument, undefined) + this.changes.add(callback) + const disposable: Disposable = { + dispose: () => { + this.changes.delete(callback) + }, + } + disposables?.push(disposable) + return disposable + } + + renew(path: string, revision: string): void { + let current = this.revisions.get(path) + if (!current || current.revision !== revision) { + current?.controller.abort() + current = { revision, controller: new AbortController(), failures: new Map(), budgets: new Map() } + this.revisions.set(path, current) + const failure = this.entries.get(path) + if (failure) { + this.entries.set(path, { ...failure, revision, state: "exhausted" }) + this.changed() + } + } + } + + isCurrent(path: string, revision: string): boolean { + const current = this.revisions.get(path) + return current?.revision === revision && !current.controller.signal.aborted + } + + getRetryBudget(path: string, revision: string, activity: FileFailureActivity): RetryBudget { + const current = this.revisions.get(path) + if (current?.revision !== revision) return { retriesUsed: 0 } + let budget = current.budgets.get(activity) + if (!budget) { + budget = { retriesUsed: 0 } + current.budgets.set(activity, budget) + } + return budget + } + + record( + path: string, + revision: string, + activity: FileFailureActivity, + reason: string, + attempt: number, + state: FileFailureState, + classification: IndexingErrorClassification = "permanent", + delay?: Pick, + ): void { + if (!this.isCurrent(path, revision)) return + const failure = { path, revision, activity, reason, attempt, state, classification, ...delay } + this.revisions.get(path)!.failures.set(activity, failure) + this.entries.set(path, failure) + this.changed() + } + + clear(path: string, revision?: string): void { + if (revision !== undefined && !this.isCurrent(path, revision)) return + this.revisions.get(path)?.failures.clear() + this.revisions.get(path)?.budgets.clear() + if (this.entries.delete(path)) this.changed() + } + + resetAllBudgets(): void { + for (const current of this.revisions.values()) { + current.controller.abort() + current.controller = new AbortController() + current.failures.clear() + current.budgets.clear() + } + for (const [path, failure] of this.entries) this.entries.set(path, { ...failure, state: "exhausted" }) + this.changed() + } + + list(): FailedFile[] { + return [...this.entries.values()].map((failure) => ({ ...failure })) + } + + private changed(): void { + for (const listener of this.changes) listener() + } +} diff --git a/src/services/code-index/file-index-writer.ts b/src/services/code-index/file-index-writer.ts new file mode 100644 index 0000000000..77775a67cc --- /dev/null +++ b/src/services/code-index/file-index-writer.ts @@ -0,0 +1,126 @@ +import { v5 as uuidv5 } from "uuid" +import type { CodeBlock, IEmbedder, IVectorStore, PointStruct } from "./interfaces" +import type { ICacheManager } from "./interfaces/cache" +import { BATCH_SEGMENT_THRESHOLD, QDRANT_CODE_BLOCK_NAMESPACE } from "./constants" +import { FileFailureRegistry, REMOVAL_REVISION, type FileFailureActivity } from "./file-failure-registry" +import type { ProviderPacerRegistry } from "./provider-pacer" +import { retryWithPolicy } from "./shared/retry-policy" +import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "./shared/get-relative-path" + +/** Embeds, saves and removes the blocks of one file; the only place code-index embeds run. */ +export class FileIndexWriter { + constructor( + private readonly embedder: IEmbedder, + private readonly store: IVectorStore, + private readonly cache: Pick, + private readonly failures: FileFailureRegistry, + private readonly pacers: ProviderPacerRegistry, + private readonly workspacePath: string, + private readonly batchSize = BATCH_SEGMENT_THRESHOLD, + ) {} + + /** Replaces the file's points with `blocks`; returns false when the file failed and was recorded. */ + async replace(filePath: string, hash: string, blocks: CodeBlock[], signal?: AbortSignal): Promise { + const relativePath = generateRelativeFilePath(filePath, this.workspacePath) + // A failed earlier attempt may have upserted some batches before any hash was stored. + const mayHavePoints = + this.cache.getHash(filePath) !== undefined || + this.failures.list().some((failure) => failure.path === relativePath) + this.failures.renew(relativePath, hash) + const selected = blocks.filter((block) => block.content.trim()) + try { + if (mayHavePoints) { + await this.attempt(relativePath, hash, "delete", this.store.rateLimitScope, signal, () => + this.store.deletePointsByFilePath(filePath), + ) + // The old points are gone, so the old hash must not let an unchanged file skip re-indexing, + // even when a stop landed while the delete was in flight. + this.cache.deleteHash(filePath) + } + for (let offset = 0; offset < selected.length; offset += this.batchSize) { + const batch = selected.slice(offset, offset + this.batchSize) + const { embeddings } = await this.attempt( + relativePath, + hash, + "embed", + this.embedder.rateLimitScope, + signal, + () => this.embedder.createEmbeddings(batch.map((block) => block.content)), + ) + const points = batch.map((block, index) => this.toPoint(block, embeddings[index])) + await this.attempt(relativePath, hash, "upsert", this.store.rateLimitScope, signal, () => + this.store.upsertPoints(points), + ) + } + } catch (error) { + if (signal?.aborted) throw error + return false + } + if (signal?.aborted) return false + this.cache.updateHash(filePath, hash) + this.failures.clear(relativePath, hash) + return true + } + + /** Deletes the file's points and hash; returns false when the removal failed and was recorded. */ + async remove(filePath: string, signal?: AbortSignal): Promise { + const relativePath = generateRelativeFilePath(filePath, this.workspacePath) + this.failures.renew(relativePath, REMOVAL_REVISION) + try { + await this.attempt(relativePath, REMOVAL_REVISION, "delete", this.store.rateLimitScope, signal, () => + this.store.deletePointsByFilePath(filePath), + ) + } catch (error) { + if (signal?.aborted) throw error + return false + } + if (signal?.aborted) return false + this.cache.deleteHash(filePath) + this.failures.clear(relativePath, REMOVAL_REVISION) + return true + } + + private attempt( + relativePath: string, + revision: string, + activity: FileFailureActivity, + scope: string, + signal: AbortSignal | undefined, + request: () => Promise, + ): Promise { + return retryWithPolicy(() => this.pacers.get(scope).run(relativePath, signal, request), { + signal, + activity, + budget: this.failures.getRetryBudget(relativePath, revision, activity), + onFailure: ({ error, attempt, willRetry, classification, ...delay }) => { + if (signal?.aborted) return + this.failures.record( + relativePath, + revision, + activity, + error instanceof Error ? error.message : String(error), + attempt, + willRetry ? "retrying" : "exhausted", + classification, + delay, + ) + }, + }) + } + + // Segment hash, not start line: segments split from one long line share a start line. + private toPoint(block: CodeBlock, vector: number[]): PointStruct { + const absolutePath = generateNormalizedAbsolutePath(block.file_path, this.workspacePath) + return { + id: uuidv5(block.segmentHash, QDRANT_CODE_BLOCK_NAMESPACE), + vector, + payload: { + filePath: generateRelativeFilePath(absolutePath, this.workspacePath), + codeChunk: block.content, + startLine: block.start_line, + endLine: block.end_line, + segmentHash: block.segmentHash, + }, + } + } +} diff --git a/src/services/code-index/interfaces/directory-scanner-factory.ts b/src/services/code-index/interfaces/directory-scanner-factory.ts index 0e55a220bb..707398b6be 100644 --- a/src/services/code-index/interfaces/directory-scanner-factory.ts +++ b/src/services/code-index/interfaces/directory-scanner-factory.ts @@ -1,5 +1,7 @@ import type { Ignore } from "ignore" import type { CacheManager } from "../cache-manager" +import type { FileFailureRegistry } from "../file-failure-registry" +import type { ProviderPacerRegistry } from "../provider-pacer" import type { ICodeParser, IDirectoryScanner, IEmbedder, IVectorStore } from "./index" export interface DirectoryScannerFactoryOptions { @@ -8,6 +10,8 @@ export interface DirectoryScannerFactoryOptions { parser: ICodeParser cacheManager: CacheManager ignoreInstance: Ignore + failureRegistry: FileFailureRegistry + pacers: ProviderPacerRegistry } export interface IDirectoryScannerFactory { diff --git a/src/services/code-index/interfaces/embedder-factory.ts b/src/services/code-index/interfaces/embedder-factory.ts index 013b3f372b..96fbf84bf6 100644 --- a/src/services/code-index/interfaces/embedder-factory.ts +++ b/src/services/code-index/interfaces/embedder-factory.ts @@ -1,7 +1,8 @@ import type { CodeIndexConfig } from "./config" import type { IEmbedder } from "./index" +import type { ProviderPacerRegistry } from "../provider-pacer" /** Creates a vector embedder, validating the provider's required configuration. */ export interface IEmbedderFactory { - create(config: CodeIndexConfig): IEmbedder + create(config: CodeIndexConfig, pacers: ProviderPacerRegistry): IEmbedder } diff --git a/src/services/code-index/interfaces/embedder.ts b/src/services/code-index/interfaces/embedder.ts index bd148bb22d..38c227db19 100644 --- a/src/services/code-index/interfaces/embedder.ts +++ b/src/services/code-index/interfaces/embedder.ts @@ -18,6 +18,9 @@ export interface IEmbedder { validateConfiguration(): Promise<{ valid: boolean; error?: string }> get embedderInfo(): EmbedderInfo + + /** Requests with the same scope share one provider allowance and its rate-limit wait. */ + readonly rateLimitScope: string } export interface EmbeddingResponse { diff --git a/src/services/code-index/interfaces/file-processor.ts b/src/services/code-index/interfaces/file-processor.ts index 8ecdc518c8..96ef3fd962 100644 --- a/src/services/code-index/interfaces/file-processor.ts +++ b/src/services/code-index/interfaces/file-processor.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { PointStruct } from "./vector-store" +import type { FileFailureActivity } from "../file-failure-registry" /** * Interface for code file parser @@ -54,7 +54,7 @@ export interface IFileWatcher extends vscode.Disposable { /** * Initializes the file watcher */ - initialize(): Promise + initialize(signal?: AbortSignal): Promise /** * Event emitted when a batch of files begins processing. @@ -94,11 +94,12 @@ export interface BatchProcessingSummary { export interface FileProcessingResult { path: string - status: "success" | "skipped" | "error" | "processed_for_batching" | "local_error" + status: "success" | "skipped" | "error" | "processed_for_batching" | "local_error" | "missing" error?: Error reason?: string newHash?: string - pointsToUpsert?: PointStruct[] + blocks?: CodeBlock[] + activity?: FileFailureActivity } /** diff --git a/src/services/code-index/interfaces/file-watcher-factory.ts b/src/services/code-index/interfaces/file-watcher-factory.ts index 94fc8882fc..05aa844602 100644 --- a/src/services/code-index/interfaces/file-watcher-factory.ts +++ b/src/services/code-index/interfaces/file-watcher-factory.ts @@ -2,6 +2,8 @@ import type * as vscode from "vscode" import type { Ignore } from "ignore" import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" import type { CacheManager } from "../cache-manager" +import type { FileFailureRegistry } from "../file-failure-registry" +import type { ProviderPacerRegistry } from "../provider-pacer" import type { IEmbedder, IFileWatcher, IVectorStore } from "./index" export interface FileWatcherFactoryOptions { @@ -12,6 +14,8 @@ export interface FileWatcherFactoryOptions { cacheManager: CacheManager ignoreInstance: Ignore rooIgnoreController?: RooIgnoreController + failureRegistry: FileFailureRegistry + pacers: ProviderPacerRegistry } export interface IFileWatcherFactory { diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index cab376e1b6..dcecf8bc6d 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -9,3 +9,5 @@ export type EmbedderProvider = | "bedrock" | "openrouter" | "semble" + +export type { IndexingStatus as IndexProgressUpdate, ProviderWaitStatus } from "@roo-code/types" diff --git a/src/services/code-index/interfaces/vector-store-factory.ts b/src/services/code-index/interfaces/vector-store-factory.ts index 4a4361ef35..033a970682 100644 --- a/src/services/code-index/interfaces/vector-store-factory.ts +++ b/src/services/code-index/interfaces/vector-store-factory.ts @@ -1,6 +1,7 @@ import type { CodeIndexConfig } from "./config" import type { IVectorStore } from "./index" +import type { ProviderPacerRegistry } from "../provider-pacer" export interface IVectorStoreFactory { - create(config: CodeIndexConfig, workspacePath: string): IVectorStore + create(config: CodeIndexConfig, workspacePath: string, pacers: ProviderPacerRegistry): IVectorStore } diff --git a/src/services/code-index/interfaces/vector-store.ts b/src/services/code-index/interfaces/vector-store.ts index 7946563fd5..22d5f4307a 100644 --- a/src/services/code-index/interfaces/vector-store.ts +++ b/src/services/code-index/interfaces/vector-store.ts @@ -10,9 +10,13 @@ export type PointStruct = { export interface IVectorStore { /** * Initializes the vector store + * @param signal Ends retry and rate-limit waits during collection and index creation * @returns Promise resolving to boolean indicating if a new collection was created */ - initialize(): Promise + initialize(signal?: AbortSignal): Promise + + /** Requests with the same scope share one service allowance and its rate-limit wait. */ + readonly rateLimitScope: string /** * Upserts points into the vector store diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 26ce8de5cc..5ec3ee501c 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -1,20 +1,23 @@ import * as vscode from "vscode" import { ContextProxy } from "../../core/config/ContextProxy" -import { VectorStoreSearchResult } from "./interfaces" import { IndexingState } from "./interfaces/manager" import { CodeIndexConfigManager } from "./config-manager" import type { CodeIndexStateManager } from "./state-manager" import { CodeIndexServiceFactory } from "./service-factory" -import { CodeIndexSearchService } from "./search-service" +import { CodeIndexSearchService, type CodeIndexSearchResponse } from "./search-service" import { CodeIndexOrchestrator } from "./orchestrator" import { CacheManager } from "./cache-manager" import { SembleProvider } from "./semble" +import { FileFailureRegistry } from "./file-failure-registry" +import { ProviderPacerRegistry } from "./provider-pacer" +import { formatProviderWaitMessage } from "./shared/provider-wait-message" import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" import fs from "fs/promises" import ignore from "ignore" import path from "path" import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName } from "@roo-code/types" +import { TelemetryEventName, type FailedFileSummary } from "@roo-code/types" +import { t } from "../../i18n" export class CodeIndexManager { // Specialized class instances @@ -26,6 +29,11 @@ export class CodeIndexManager { private _cacheManager: CacheManager | undefined private _sembleProvider: SembleProvider | undefined + // One registry and pacer set per workspace: recreating services must not reset retry budgets or provider waits. + private readonly failureRegistry = new FileFailureRegistry() + private readonly pacers = new ProviderPacerRegistry() + private startInFlight?: Promise + // Flag to prevent race conditions during error recovery private _isRecoveringFromError = false @@ -64,6 +72,7 @@ export class CodeIndexManager { public async setWorkspaceEnabled(enabled: boolean): Promise { await this.context.workspaceState.update(this._workspaceEnabledKey(), enabled) + if (enabled) this.failureRegistry.resetAllBudgets() } public get autoEnableDefault(): boolean { @@ -137,7 +146,7 @@ export class CodeIndexManager { // 2. Check if feature is enabled if (!this.isFeatureEnabled) { if (this._orchestrator) { - this._orchestrator.stopWatcher() + await this._orchestrator.stopIndexing() } if (this._sembleProvider) { this._sembleProvider.stopIndexing() @@ -158,11 +167,10 @@ export class CodeIndexManager { return { requiresRestart } } - // 5. CacheManager Initialization - if (!this._cacheManager) { - this._cacheManager = new CacheManager(this.context, this.workspacePath) - await this._cacheManager.initialize() - } + // 5. CacheManager Initialization; an unreadable cache ends here so existing index data is kept. + if (!(await this.ensureCacheManager())) return { requiresRestart } + // The cache read yields; indexing may have been disabled meanwhile. + if (!this.isFeatureEnabled || !this.isWorkspaceEnabled) return { requiresRestart } // 6. Determine if Core Services Need Recreation const needsServiceRecreation = (!this._serviceFactory && !this._sembleProvider) || requiresRestart @@ -191,18 +199,46 @@ export class CodeIndexManager { return { requiresRestart } } + /** Returns false when the cache could not be read; status is then Error and Start Indexing retries. */ + private async ensureCacheManager(): Promise { + if (this._cacheManager) return true + const cacheManager = new CacheManager(this.context, this.workspacePath) + try { + await cacheManager.initialize() + } catch (error) { + console.error("[CodeIndexManager] Failed to read the index cache:", error) + this._stateManager.setSystemState( + "Error", + error instanceof Error ? error.message : t("embeddings:orchestrator.cacheReadFailed"), + ) + return false + } + this._cacheManager = cacheManager + return true + } + private startIndexingInBackground(): void { - void this._orchestrator?.startIndexing().catch((error) => { + // Initialization awaits the cache read; indexing may have been disabled meanwhile. + if (!this.isFeatureEnabled || !this.isWorkspaceEnabled) return + void this.startOrchestrator().catch((error) => { console.error("[CodeIndexManager] Background indexing failed:", error) }) } + private startOrchestrator(): Promise { + if (this.startInFlight) return this.startInFlight + const orchestrator = this._orchestrator + if (!orchestrator) return Promise.resolve() + this.startInFlight = orchestrator.startIndexing().finally(() => { + this.startInFlight = undefined + }) + return this.startInFlight + } + /** * Initiates the indexing process (initial scan and starts watcher). * Automatically recovers from error state if needed before starting. - * - * @important This method should NEVER be awaited as it starts a long-running background process. - * The indexing will continue asynchronously and progress will be reported through events. + * A repeated call while a start or scan is running returns that start's promise (R13). */ public async startIndexing(): Promise { if (!this.isFeatureEnabled || !this.isWorkspaceEnabled) { @@ -215,57 +251,52 @@ export class CodeIndexManager { return } - // Check if we're in error state and recover if needed - const currentStatus = this.getCurrentStatus() - if (currentStatus.systemStatus === "Error") { - await this.recoverFromError() + if (this.startInFlight) return this.startInFlight + this.failureRegistry.resetAllBudgets() - // After recovery, we need to reinitialize since recoverFromError clears all services - // This will be handled by the caller (webviewMessageHandler) checking isInitialized + if (this.getCurrentStatus().systemStatus === "Error") { + // Recovery drops the config manager, so keep its proxy to re-initialize; initialize() starts indexing. + const contextProxy = this._configManager?.getContextProxy() + await this.recoverFromError() + if (!contextProxy) return + await this.initialize(contextProxy) + await this.startInFlight return } this.assertInitialized() - await this._orchestrator!.startIndexing() + await this.startOrchestrator() } /** * Stops any in-progress indexing operation and the file watcher. + * Repeated calls while stopping return the same promise. */ - public stopIndexing(): void { + public stopIndexing(): Promise { if (this._sembleProvider) { this._sembleProvider.stopIndexing() - return - } - if (this._orchestrator) { - this._orchestrator.stopIndexing() + return Promise.resolve() } + return this._orchestrator?.stopIndexing() ?? Promise.resolve() } /** * Stops the file watcher and potentially cleans up resources. */ - public stopWatcher(): void { + public stopWatcher(): Promise { if (!this.isFeatureEnabled) { - return - } - if (this._orchestrator) { - this._orchestrator.stopWatcher() + return Promise.resolve() } + return this._orchestrator?.stopWatcher() ?? Promise.resolve() } /** - * Recovers from error state by clearing the error and resetting internal state. - * This allows the manager to be re-initialized after a recoverable error. - * - * This method clears all service instances (configManager, serviceFactory, orchestrator, searchService) - * to force a complete re-initialization on the next operation. This ensures a clean slate - * after recovering from errors such as network failures or configuration issues. + * Recovers from error state by stopping the current scan and watcher, clearing the error and + * dropping service instances so the next initialize() recreates them. * * @remarks * - Safe to call even when not in error state (idempotent) * - Does not restart indexing automatically - call initialize() after recovery - * - Service instances will be recreated on next initialize() call * - Prevents race conditions from multiple concurrent recovery attempts */ public async recoverFromError(): Promise { @@ -276,7 +307,8 @@ export class CodeIndexManager { this._isRecoveringFromError = true try { - // Clear error state + // The old scan and watcher must stop before replacement work starts (R37). + await this._orchestrator?.stopIndexing() this._stateManager.setSystemState("Standby", "") } catch (error) { // Log error but continue with recovery - clearing service instances is more important @@ -289,6 +321,7 @@ export class CodeIndexManager { this._orchestrator = undefined this._searchService = undefined this._sembleProvider = undefined + this._cacheManager = undefined // Reset the flag after recovery is complete this._isRecoveringFromError = false @@ -299,7 +332,7 @@ export class CodeIndexManager { * Cleans up the manager instance. */ public dispose(): void { - this.stopIndexing() + void this.stopIndexing() if (this._sembleProvider) { this._sembleProvider.dispose() this._sembleProvider = undefined @@ -326,22 +359,57 @@ export class CodeIndexManager { // --- Private Helpers --- + /** + * Status precedence: Error > Stopping > active indexing or provider wait > idle partial (Standby with + * failures) > Indexed > Standby. + */ public getCurrentStatus() { const status = this._stateManager.getCurrentStatus() + const failedFiles = this.failureRegistry.list().map((failure) => ({ + path: failure.path, + activity: failure.activity, + reason: failure.reason, + state: failure.state, + })) + const representative = failedFiles.find((failure) => failure.state === "retrying") ?? failedFiles.at(0) + const failedFileSummary: FailedFileSummary | undefined = representative && { + ...representative, + count: failedFiles.length, + } + const providerWaits = this.pacers.providerWaits() + let systemStatus: IndexingState = status.systemStatus + let message = status.message + if (systemStatus !== "Error" && systemStatus !== "Stopping") { + if (this.pacers.hasWaitingRequests()) { + systemStatus = "Indexing" + message = formatProviderWaitMessage(providerWaits) + } else if (this._orchestrator?.isScanning) { + systemStatus = "Indexing" + } else if (systemStatus !== "Indexing" && failedFiles.length > 0) { + systemStatus = "Standby" + message = t("embeddings:orchestrator.indexingFilesFailed") + } + } return { ...status, + systemStatus, + message, + failedFiles, + failedFileSummary, + providerWaits, workspacePath: this.workspacePath, workspaceEnabled: this.isWorkspaceEnabled, autoEnableDefault: this.autoEnableDefault, } } - public async searchIndex(query: string, directoryPrefix?: string): Promise { + public async searchIndex(query: string, directoryPrefix?: string): Promise { + const empty: CodeIndexSearchResponse = { results: [], incomplete: false, failedPaths: [], waitingPaths: [] } if (!this.isFeatureEnabled) { - return [] + return empty } if (this._sembleProvider) { - return this._sembleProvider.searchIndex(query, directoryPrefix) + return { ...empty, results: await this._sembleProvider.searchIndex(query, directoryPrefix) } } this.assertInitialized() return this._searchService!.searchIndex(query, directoryPrefix) @@ -352,9 +420,9 @@ export class CodeIndexManager { * Used by both initialize() and handleSettingsChange(). */ private async _recreateServices(): Promise { - // Stop watcher if it exists + // The old scan and watcher must stop before replacement services start (R37). if (this._orchestrator) { - this.stopWatcher() + await this._orchestrator.stopIndexing() } // Dispose existing semble provider if switching away if (this._sembleProvider) { @@ -377,6 +445,8 @@ export class CodeIndexManager { this._configManager!, this.workspacePath, this._cacheManager!, + this.failureRegistry, + this.pacers, ) const ignoreInstance = ignore() @@ -424,6 +494,8 @@ export class CodeIndexManager { vectorStore, scanner, fileWatcher, + this.failureRegistry, + this.pacers, ) // (Re)Initialize search service @@ -432,6 +504,8 @@ export class CodeIndexManager { this._stateManager, embedder, vectorStore, + this.failureRegistry, + this.pacers, ) // Clear any error state after successful recreation @@ -453,7 +527,7 @@ export class CodeIndexManager { // If feature is disabled, stop the service (including any active scan) if (!isFeatureEnabled) { - this.stopIndexing() + await this.stopIndexing() this._stateManager.setSystemState("Standby", "Code indexing is disabled") return } @@ -461,21 +535,22 @@ export class CodeIndexManager { if (requiresRestart && isFeatureEnabled && isFeatureConfigured) { try { // Ensure cacheManager is initialized before recreating services - if (!this._cacheManager) { - this._cacheManager = new CacheManager(this.context, this.workspacePath) - await this._cacheManager.initialize() - } + if (!(await this.ensureCacheManager())) return // Recreate services with new configuration await this._recreateServices() } catch (error) { - // Error state already set in _recreateServices console.error("Failed to recreate services:", error) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, location: "handleSettingsChange", }) + // Replacement failures leave indexing restartable from Error. + this._stateManager.setSystemState( + "Error", + error instanceof Error ? error.message : t("embeddings:orchestrator.unknownError"), + ) // Re-throw the error so the caller knows validation failed throw error } diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 445db2364e..8c192c88d1 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -6,17 +6,28 @@ import { IFileWatcher, IVectorStore, BatchProcessingSummary } from "./interfaces import { DirectoryScanner } from "./processors" import { CacheManager } from "./cache-manager" import { CodeIndexScanExecutor } from "./code-index-scan-executor" +import type { FileFailureRegistry } from "./file-failure-registry" +import type { ProviderPacerRegistry } from "./provider-pacer" +import { formatProviderWaitMessage } from "./shared/provider-wait-message" 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. + * + * One `AbortController` covers a whole session (scan and watcher). Every status write except the final + * Standby in `stop()` first checks that controller's signal, so a stopped session cannot overwrite a later one. */ export class CodeIndexOrchestrator { private _fileWatcherSubscriptions: vscode.Disposable[] = [] - private _isProcessing: boolean = false private _abortController: AbortController | null = null + private scanPromise?: Promise + private stopPromise?: Promise + private scanDone = false + // A count, not a flag: the watcher queues batches, so one batch can finish while another is pending. + private pendingWatcherBatches = 0 + private recoverySubscriptions: vscode.Disposable[] = [] private readonly scanExecutor: CodeIndexScanExecutor constructor( @@ -27,26 +38,54 @@ export class CodeIndexOrchestrator { private readonly vectorStore: IVectorStore, scanner: DirectoryScanner, private readonly fileWatcher: IFileWatcher, + private readonly failures: FileFailureRegistry, + private readonly pacers: ProviderPacerRegistry, ) { this.scanExecutor = new CodeIndexScanExecutor(workspacePath, scanner, vectorStore, stateManager) } - /** - * Starts the file watcher if not already running. - */ - private async _startWatcher(): Promise { + private setState(signal: AbortSignal, state: IndexingState, message?: string): void { + if (!signal.aborted) this.stateManager.setSystemState(state, message) + } + + private hasOutstandingWork(): boolean { + return this.failures.list().length > 0 || this.pacers.hasWaitingRequests() + } + + /** Reflects failures and provider waits in the status; never replaces Stopping or Error. */ + private updateRecoveryStatus(signal: AbortSignal): void { + const state = this.stateManager.state + if (signal.aborted || state === "Stopping" || state === "Error") return + if (this.pacers.hasWaitingRequests()) { + this.stateManager.setSystemState("Indexing", formatProviderWaitMessage(this.pacers.providerWaits())) + return + } + if (!this.scanDone || this.pendingWatcherBatches > 0) return + if (this.failures.list().length > 0) { + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingFilesFailed")) + return + } + if (state !== "Indexed") { + this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) + } + } + + private async _startWatcher(signal: AbortSignal): Promise { if (!this.configManager.isFeatureConfigured) { throw new Error("Cannot start watcher: Service not configured.") } - this.stateManager.setSystemState("Indexing", "Initializing file watcher...") + this.setState(signal, "Indexing", "Initializing file watcher...") try { - await this.fileWatcher.initialize() + await this.fileWatcher.initialize(signal) this._fileWatcherSubscriptions = [ - this.fileWatcher.onDidStartBatchProcessing((_filePaths: string[]) => {}), + this.fileWatcher.onDidStartBatchProcessing(() => { + this.pendingWatcherBatches++ + }), this.fileWatcher.onBatchProgressUpdate(({ processedInBatch, totalInBatch, currentFile }) => { + if (signal.aborted || this.stateManager.state === "Stopping") return if (totalInBatch > 0 && this.stateManager.state !== "Indexing") { this.stateManager.setSystemState("Indexing", "Processing file changes...") } @@ -55,23 +94,17 @@ export class CodeIndexOrchestrator { 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.") - } - } + // The watcher reports progress after the batch-finished event; settle the status again. + if (processedInBatch === totalInBatch && this.pendingWatcherBatches === 0) { + this.updateRecoveryStatus(signal) } }), - this.fileWatcher.onDidFinishBatchProcessing((summary: BatchProcessingSummary) => { - if (summary.batchError) { + this.fileWatcher.onDidFinishBatchProcessing((summary?: BatchProcessingSummary) => { + if (summary?.batchError) { console.error(`[CodeIndexOrchestrator] Batch processing failed:`, summary.batchError) } + this.pendingWatcherBatches = Math.max(0, this.pendingWatcherBatches - 1) + this.updateRecoveryStatus(signal) }), ] } catch (error) { @@ -86,14 +119,14 @@ export class CodeIndexOrchestrator { } /** - * Updates the status of a file in the state manager. - */ - - /** - * Initiates the indexing process (initial scan and starts watcher). + * Starts one indexing session (scan, then watcher). A repeated call while a scan runs returns the running + * scan; a call while stopping waits for the stop to finish first; a call during the watcher phase stops + * that session and rescans, so an explicit retry reaches failed files. */ public async startIndexing(): Promise { - // Check if workspace is available first + if (this.stopPromise) await this.stopPromise + if (this.scanPromise) return this.scanPromise + 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.") @@ -106,75 +139,56 @@ export class CodeIndexOrchestrator { 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 + if (this._abortController && !this._abortController.signal.aborted) { + await this.stopIndexing() + if (this.scanPromise) return this.scanPromise } - this._isProcessing = true + this.scanDone = false this._abortController = new AbortController() const signal = this._abortController.signal + this.recoverySubscriptions = [ + this.failures.onDidChange(() => this.updateRecoveryStatus(signal)), + this.pacers.onDidChange(() => this.updateRecoveryStatus(signal)), + ] this.stateManager.setSystemState("Indexing", "Initializing services...") + this.scanPromise = this.runScan(signal).finally(() => { + this.scanPromise = undefined + }) + return this.scanPromise + } + private async runScan(signal: AbortSignal): Promise { // Only clean up collections created by this run; existing data must survive failed retries. let clearIndexOnError = false try { - const collectionCreated = await this.vectorStore.initialize() - + const collectionCreated = await this.vectorStore.initialize(signal) clearIndexOnError = collectionCreated - if (collectionCreated) { + if (collectionCreated && !signal.aborted) { 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() + const completed = + hasExistingData && !collectionCreated + ? await this.scanExecutor.runIncrementalScan(signal) + : await this.scanExecutor.runFullScan(signal) + if (!completed || signal.aborted) return - if (hasExistingData && !collectionCreated) { - if (!(await this.scanExecutor.runIncrementalScan(signal))) { - await this.cacheManager.flush() - this.stopWatcher() - this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) - return - } - - await this._startWatcher() - - // Mark indexing as complete after successful incremental scan - await this.vectorStore.markIndexingComplete() - - this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) - } else { - if (!(await this.scanExecutor.runFullScan(signal))) { - await this.cacheManager.flush() - this.stopWatcher() - this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) - return - } - - await this._startWatcher() + await this._startWatcher(signal) + if (signal.aborted) return - // Mark indexing as complete after successful full scan + this.scanDone = true + if (!this.scanExecutor.partial && !this.hasOutstandingWork()) { await this.vectorStore.markIndexingComplete() - - this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) } + this.updateRecoveryStatus(signal) } catch (error: any) { - // Handle abort gracefully — not an error, just a user-initiated stop + // A stop aborts in-flight work; that is not an indexing failure. 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 } @@ -197,50 +211,68 @@ export class CodeIndexOrchestrator { } // 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 { console.log("[CodeIndexOrchestrator] Preserving existing index and cache for a retry.") } - this.stateManager.setSystemState( + this.setState( + signal, "Error", t("embeddings:orchestrator.failedDuringInitialScan", { - errorMessage: error.message || t("embeddings:orchestrator.unknownError"), + errorMessage: error?.message || t("embeddings:orchestrator.unknownError"), }), ) - this.stopWatcher() - } finally { - this._isProcessing = false - this._abortController = null + this.disposeWatcher() } } /** - * Stops any in-progress indexing by aborting the scan and stopping the file watcher. + * Stops the session: aborts the scan, provider waits and retry waits at once, disposes the watcher, + * then writes Standby. Repeated calls while stopping join the same promise. */ - public stopIndexing(): void { - if (this._abortController) { - this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial")) - this._abortController.abort() - this._abortController = null + public stopIndexing(): Promise { + if (this.stopPromise) return this.stopPromise + const controller = this._abortController + if (!controller || controller.signal.aborted) { + this.disposeWatcher() + return Promise.resolve() } - this.stopWatcher() + const preserveError = this.stateManager.state === "Error" + if (!preserveError) this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStopping")) + controller.abort() + this.disposeWatcher() + const scan = this.scanPromise + this.stopPromise = (async () => { + await scan?.catch(() => {}) + await this.cacheManager.flush() + if (!preserveError) { + this.stateManager.setSystemState( + "Standby", + t( + this.hasOutstandingWork() || !this.scanDone + ? "embeddings:orchestrator.indexingStoppedPartial" + : "embeddings:orchestrator.indexingStopped", + ), + ) + } + })().finally(() => { + this.stopPromise = undefined + }) + return this.stopPromise } - /** - * Stops the file watcher and cleans up resources. - */ - public stopWatcher(): void { + /** Stops scan and watcher; kept for callers that only need the watcher gone. */ + public stopWatcher(): Promise { + return this.stopIndexing() + } + + private disposeWatcher(): 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 + this.recoverySubscriptions.forEach((sub) => sub.dispose()) + this.recoverySubscriptions = [] + this.pendingWatcherBatches = 0 } /** @@ -248,37 +280,35 @@ export class CodeIndexOrchestrator { * and resetting the cache file. */ public async clearIndexData(): Promise { - this._isProcessing = true + await this.stopIndexing() 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}`) + 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() + await this.cacheManager.clearCacheFile() - if (this.stateManager.state !== "Error") { - this.stateManager.setSystemState("Standby", "Index data cleared successfully.") - } - } finally { - this._isProcessing = false + if (this.stateManager.state !== "Error") { + this.stateManager.setSystemState("Standby", "Index data cleared successfully.") } } + public get isScanning(): boolean { + return this.scanPromise !== undefined + } + /** * Gets the current state of the indexing system. */ diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 22020a9fdd..6ba76c3194 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -1,9 +1,9 @@ import { createHash } from "crypto" import path from "path" import { FileType, Uri } from "vscode" -import { v5 as uuidv5 } from "uuid" -import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" -import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" +import type { CodeBlock, ICodeParser } from "../../interfaces" +import { MAX_FILE_SIZE_BYTES } from "../../constants" +import { ParserLoadError } from "../parser" import { FilePreparation } from "../file-preparation" import type { FilePreparationDependencies } from "../file-preparation-dependencies" @@ -24,7 +24,6 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo const filePath = "/workspace/src/file.ts" const relativeFilePath = path[platform].join("src", "file.ts") - const normalizedFilePath = path[platform].resolve(filePath) const content = "test content" const hash = createHash("sha256").update(content).digest("hex") const block: CodeBlock = { @@ -57,11 +56,6 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo }, cacheManager: { getHash: vi.fn() }, parser: { parseFile: vi.fn().mockResolvedValue([{ ...block }]) }, - embedder: { - createEmbeddings: vi - .fn() - .mockResolvedValue({ embeddings: [[0.1, 0.2]] }), - }, } satisfies FilePreparationDependencies } @@ -81,7 +75,6 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() expect(dependencies.parser.parseFile).not.toHaveBeenCalled() - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }, ) @@ -104,7 +97,6 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() expect(dependencies.parser.parseFile).not.toHaveBeenCalled() - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) it("skips oversized files without reading them", async () => { @@ -123,10 +115,9 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() expect(dependencies.parser.parseFile).not.toHaveBeenCalled() - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) - it("skips unchanged content before parsing or embedding", async () => { + it("skips unchanged content before parsing", async () => { const dependencies = setup() dependencies.cacheManager.getHash.mockReturnValue(hash) expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ @@ -136,37 +127,28 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo }) expect(dependencies.cacheManager.getHash).toHaveBeenCalledWith(filePath) expect(dependencies.parser.parseFile).not.toHaveBeenCalled() - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) - it("returns the hash and empty points when parsing produces no blocks", async () => { + it("returns the hash and no blocks when parsing produces none", async () => { const dependencies = setup() dependencies.parser.parseFile.mockResolvedValue([]) expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "processed_for_batching", newHash: hash, - pointsToUpsert: [], + blocks: [], }) - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) - it("still parses and returns the hash without an embedder or gitignore", async () => { + it("still parses and returns the hash without gitignore", async () => { const dependencies = setup() expect( - await new FilePreparation({ ...dependencies, embedder: undefined, ignoreInstance: undefined }).prepareFile( - filePath, - ), - ).toEqual({ - path: filePath, - status: "processed_for_batching", - newHash: hash, - pointsToUpsert: [], - }) + (await new FilePreparation({ ...dependencies, ignoreInstance: undefined }).prepareFile(filePath)).status, + ).toBe("processed_for_batching") expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content, fileHash: hash }) }) - it("preserves normalized paths, stable IDs and embedding order at the size limit", async () => { + it("CI-FP-03: returns parsed blocks and the hash at the size limit and never embeds", async () => { const dependencies = setup() dependencies.fileSystem.stat.mockResolvedValue({ type: FileType.File, @@ -174,37 +156,27 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo mtime: 0, size: MAX_FILE_SIZE_BYTES, }) - dependencies.parser.parseFile.mockResolvedValue([ - { ...block, file_path: "src/../src/file.ts" }, - { ...block, start_line: 8, end_line: 10, content: "second" }, - ]) - dependencies.embedder.createEmbeddings.mockResolvedValue({ - embeddings: [ - [0.1, 0.2], - [0.3, 0.4], - ], - }) - expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ - path: filePath, - status: "processed_for_batching", - newHash: hash, - pointsToUpsert: [ - { - id: uuidv5(`${normalizedFilePath}:2`, QDRANT_CODE_BLOCK_NAMESPACE), - vector: [0.1, 0.2], - payload: { filePath: relativeFilePath, codeChunk: content, startLine: 2, endLine: 5 }, - }, - { - id: uuidv5(`${normalizedFilePath}:8`, QDRANT_CODE_BLOCK_NAMESPACE), - vector: [0.3, 0.4], - payload: { filePath: relativeFilePath, codeChunk: "second", startLine: 8, endLine: 10 }, - }, - ], - }) + const blocks = [block, { ...block, start_line: 8, end_line: 10, content: "second" }] + dependencies.parser.parseFile.mockResolvedValue(blocks) + const result = await new FilePreparation(dependencies).prepareFile(filePath) + expect(result).toEqual({ path: filePath, status: "processed_for_batching", newHash: hash, blocks }) + expect(Object.keys(dependencies)).not.toContain("embedder") expect(dependencies.fileSystem.stat).toHaveBeenCalledWith(Uri.file(filePath)) expect(dependencies.fileSystem.readFile).toHaveBeenCalledWith(Uri.file(filePath)) - expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content, fileHash: hash }) - expect(dependencies.embedder.createEmbeddings).toHaveBeenCalledWith([content, "second"]) + }) + + it.each(["stat", "read"] as const)("CI-FP-01: missing source from %s returns missing", async (stage) => { + for (const code of ["ENOENT", "FileNotFound"]) { + const dependencies = setup() + const missing = Object.assign(new Error("gone"), { code }) + if (stage === "stat") dependencies.fileSystem.stat.mockRejectedValue(missing) + else dependencies.fileSystem.readFile.mockRejectedValue(missing) + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ + path: filePath, + status: "missing", + }) + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + } }) it("calls service methods with their original receivers", async () => { @@ -222,8 +194,6 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.cacheManager.getHash.mock.contexts[0]).toBe(dependencies.cacheManager) expect(dependencies.parser.parseFile).toHaveBeenCalledOnce() expect(dependencies.parser.parseFile.mock.contexts[0]).toBe(dependencies.parser) - expect(dependencies.embedder.createEmbeddings).toHaveBeenCalledOnce() - expect(dependencies.embedder.createEmbeddings.mock.contexts[0]).toBe(dependencies.embedder) }) it("does not treat a hidden workspace ancestor as an excluded directory", async () => { @@ -274,7 +244,7 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo path: filePath, status: "processed_for_batching", newHash: expectedHash, - pointsToUpsert: [], + blocks: [], }) expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content: expectedContent, @@ -290,30 +260,34 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo }) expect(dependencies.cacheManager.getHash).toHaveBeenCalledWith(filePath) expect(dependencies.parser.parseFile).not.toHaveBeenCalled() - expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) }) - it.each(["access", "ignore", "stat", "read", "cache", "parse", "embed"])( - "returns the original %s error as a local error", - async (stage) => { - const dependencies = setup() - const error = new Error(`${stage} failed`) - const fail = () => { - throw error - } - if (stage === "access") dependencies.ignoreController.validateAccess.mockImplementation(fail) - if (stage === "ignore") dependencies.ignoreInstance.ignores.mockImplementation(fail) - if (stage === "stat") dependencies.fileSystem.stat.mockRejectedValue(error) - if (stage === "read") dependencies.fileSystem.readFile.mockRejectedValue(error) - if (stage === "cache") dependencies.cacheManager.getHash.mockImplementation(fail) - if (stage === "parse") dependencies.parser.parseFile.mockRejectedValue(error) - if (stage === "embed") dependencies.embedder.createEmbeddings.mockRejectedValue(error) - const result = await new FilePreparation(dependencies).prepareFile(filePath) - expect(result).toEqual({ path: filePath, status: "local_error", error }) - expect(result.error).toBe(error) - }, - ) + it.each([ + ["access", "read"], + ["ignore", "read"], + ["stat", "read"], + ["read", "read"], + ["cache", "parse"], + ["parse", "parse"], + ["parser load", "parse"], + ] as const)("CI-FP-02: a %s error is a local error with activity %s", async (stage, activity) => { + const dependencies = setup() + const error = + stage === "parser load" ? new ParserLoadError(".ts", new Error("wasm")) : new Error(`${stage} failed`) + const fail = () => { + throw error + } + if (stage === "access") dependencies.ignoreController.validateAccess.mockImplementation(fail) + if (stage === "ignore") dependencies.ignoreInstance.ignores.mockImplementation(fail) + if (stage === "stat") dependencies.fileSystem.stat.mockRejectedValue(Object.assign(error, { code: "EACCES" })) + if (stage === "read") dependencies.fileSystem.readFile.mockRejectedValue(error) + if (stage === "cache") dependencies.cacheManager.getHash.mockImplementation(fail) + if (stage === "parse" || stage === "parser load") dependencies.parser.parseFile.mockRejectedValue(error) + const result = await new FilePreparation(dependencies).prepareFile(filePath) + expect(result).toEqual({ path: filePath, status: "local_error", activity, error }) + expect(result.error).toBe(error) + }) it("does not wrap non-Error rejections", async () => { const dependencies = setup() @@ -321,6 +295,7 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "local_error", + activity: "read", error: "read failed", }) }) diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index 736cca0806..7c5b777f3f 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -1,9 +1,12 @@ // npx vitest services/code-index/processors/__tests__/file-watcher.spec.ts +import nodePath from "path" import * as vscode from "vscode" import { FileWatcher } from "../file-watcher" import { FilePreparation } from "../file-preparation" +import { FileFailureRegistry } from "../../file-failure-registry" +import { ProviderPacerRegistry } from "../../provider-pacer" import { clearAllMocks } from "../../../../test-utils/reset" @@ -34,6 +37,7 @@ vi.mock("../parser", () => ({ content: "test content", start_line: 1, end_line: 1, + segmentHash: `segment-${filePath}`, }, ]), }, @@ -154,10 +158,12 @@ describe("FileWatcher", () => { } mockEmbedder = { + rateLimitScope: "openai:1", createEmbeddings: vi.fn().mockResolvedValue({ embeddings: [[0.1, 0.2, 0.3]] }), } mockVectorStore = { + rateLimitScope: "qdrant:1", upsertPoints: vi.fn().mockResolvedValue(undefined), deletePointsByFilePath: vi.fn().mockResolvedValue(undefined), deletePointsByMultipleFilePaths: vi.fn().mockResolvedValue(undefined), @@ -174,6 +180,10 @@ describe("FileWatcher", () => { mockEmbedder, mockVectorStore, mockIgnoreInstance, + undefined, + undefined, + new FileFailureRegistry(), + new ProviderPacerRegistry(), ) }) @@ -196,7 +206,8 @@ describe("FileWatcher", () => { expect(prepare).toHaveBeenNthCalledWith(1, path) expect(result).toBe(await prepare.mock.results[0].value) expect(result.status).toBe("processed_for_batching") - expect(result.pointsToUpsert).toHaveLength(1) + expect(result.blocks).toHaveLength(1) + expect(mockEmbedder.createEmbeddings).not.toHaveBeenCalled() expect(vscode.workspace.fs.stat).toHaveBeenCalledWith(vscode.Uri.file(path)) expect(vscode.workspace.fs.readFile).toHaveBeenCalledWith(vscode.Uri.file(path)) const secondPath = "/mock/workspace/src/second.ts" @@ -292,13 +303,13 @@ describe("FileWatcher", () => { expect(skippedPaths).toContain("/mock/workspace/dist/bundle.js") }) - it("should batch delete events after the debounce window", async () => { + it("should remove deleted files after the debounce window", async () => { // Initialize the file watcher await fileWatcher.initialize() const deletedFiles: string[] = [] - mockVectorStore.deletePointsByMultipleFilePaths.mockImplementation(async (filePaths: string[]) => { - deletedFiles.push(...filePaths) + mockVectorStore.deletePointsByFilePath.mockImplementation(async (filePath: string) => { + deletedFiles.push(filePath) }) const batchPromise = waitForNextBatch() @@ -358,6 +369,219 @@ describe("FileWatcher", () => { }) }) + describe("failures and removals", () => { + const path = "/mock/workspace/src/file.ts" + + function watcherWithRegistry() { + const failures = new FileFailureRegistry() + fileWatcher.dispose() + fileWatcher = new FileWatcher( + "/mock/workspace", + mockContext, + mockCacheManager, + mockEmbedder, + mockVectorStore, + mockIgnoreInstance, + undefined, + undefined, + failures, + new ProviderPacerRegistry(), + ) + return failures + } + + async function runChange() { + await fileWatcher.initialize() + const batchPromise = waitForNextBatch() + await mockOnDidChange({ fsPath: path }) + await flushBatch() + return batchPromise + } + + it("CI-FW-01: a missing result routes to removeFile", async () => { + const failures = watcherWithRegistry() + vi.spyOn(FilePreparation.prototype, "prepareFile").mockResolvedValueOnce({ path, status: "missing" }) + const summary = await runChange() + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledWith(path) + expect(mockCacheManager.deleteHash).toHaveBeenCalledWith(path) + expect(summary.processedFiles).toEqual([{ path, status: "success" }]) + expect(failures.list()).toEqual([]) + }) + + it.each(["read", "parse"] as const)( + "CI-FW-02: a local_error is recorded with the result's activity %s", + async (activity) => { + const failures = watcherWithRegistry() + vi.spyOn(FilePreparation.prototype, "prepareFile").mockResolvedValueOnce({ + path, + status: "local_error", + activity, + error: new Error("bad"), + }) + await runChange() + expect(failures.list()).toEqual([ + expect.objectContaining({ path: nodePath.normalize("src/file.ts"), activity, reason: "bad" }), + ]) + expect(mockEmbedder.createEmbeddings).not.toHaveBeenCalled() + }, + ) + + it("CI-FW-02: prepared blocks are passed to the writer, which embeds and stores the hash", async () => { + watcherWithRegistry() + const blocks = [ + { + file_path: path, + identifier: null, + type: "function", + start_line: 1, + end_line: 2, + content: "code", + fileHash: "h", + segmentHash: "s", + }, + ] + vi.spyOn(FilePreparation.prototype, "prepareFile").mockResolvedValueOnce({ + path, + status: "processed_for_batching", + newHash: "h", + blocks, + }) + const summary = await runChange() + expect(mockEmbedder.createEmbeddings).toHaveBeenCalledWith(["code"]) + expect(mockVectorStore.upsertPoints).toHaveBeenCalledTimes(1) + expect(mockCacheManager.updateHash).toHaveBeenCalledWith(path, "h") + expect(summary.processedFiles).toEqual([{ path, status: "success" }]) + }) + + it("runs a later delete batch only after an earlier change batch finishes", async () => { + watcherWithRegistry() + await fileWatcher.initialize() + const order: string[] = [] + let finishUpsert!: () => void + mockVectorStore.upsertPoints.mockImplementation( + () => + new Promise((resolve) => { + finishUpsert = () => { + order.push("upsert") + resolve() + } + }), + ) + mockVectorStore.deletePointsByFilePath.mockImplementation(async () => void order.push("delete")) + mockCacheManager.getHash.mockReturnValue(undefined) + mockCacheManager.updateHash.mockImplementation(() => void order.push("hash")) + mockCacheManager.deleteHash.mockImplementation(() => void order.push("unhash")) + await mockOnDidChange({ fsPath: path }) + await flushBatch() + await mockOnDidDelete({ fsPath: path }) + await flushBatch() + expect(order).toEqual([]) + finishUpsert() + await vi.advanceTimersByTimeAsync(0) + // The delete wins: its points and hash removal come after the change's writes. + expect(order).toEqual(["upsert", "hash", "delete", "unhash"]) + }) + + it("clears a stale read failure when the content is unchanged", async () => { + const failures = watcherWithRegistry() + const relativePath = nodePath.normalize("src/file.ts") + failures.renew(relativePath, "unread") + failures.record(relativePath, "unread", "read", "busy", 1, "exhausted") + vi.spyOn(FilePreparation.prototype, "prepareFile").mockResolvedValueOnce({ + path, + status: "skipped", + reason: "File has not changed", + }) + await runChange() + expect(failures.list()).toEqual([]) + expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled() + }) + + it.each([ + { reason: "File is too large", hash: "h", failed: false, removed: true }, + { reason: "File is ignored by .rooignore or .gitignore", hash: "h", failed: false, removed: true }, + { reason: "File is too large", hash: undefined, failed: true, removed: true }, + { reason: "File is too large", hash: undefined, failed: false, removed: false }, + ])( + "a change skipped as $reason (hash $hash, failure entry $failed) removes points: $removed", + async ({ reason, hash, failed, removed }) => { + const failures = watcherWithRegistry() + const relativePath = nodePath.normalize("src/file.ts") + if (failed) { + failures.renew(relativePath, "h0") + failures.record(relativePath, "h0", "upsert", "down", 3, "exhausted") + } + mockCacheManager.getHash.mockReturnValue(hash) + vi.spyOn(FilePreparation.prototype, "prepareFile").mockResolvedValueOnce({ + path, + status: "skipped", + reason, + }) + const summary = await runChange() + if (removed) { + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledExactlyOnceWith(path) + expect(mockCacheManager.deleteHash).toHaveBeenCalledExactlyOnceWith(path) + expect(summary.processedFiles).toEqual([{ path, status: "success" }]) + } else { + expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled() + expect(mockCacheManager.deleteHash).not.toHaveBeenCalled() + expect(summary.processedFiles).toEqual([{ path, status: "skipped", reason }]) + } + expect(failures.list()).toEqual([]) + expect(mockEmbedder.createEmbeddings).not.toHaveBeenCalled() + }, + ) + + it("writes nothing after the session signal aborts", async () => { + watcherWithRegistry() + const controller = new AbortController() + await fileWatcher.initialize(controller.signal) + controller.abort() + await mockOnDidChange({ fsPath: path }) + await flushBatch() + expect(mockEmbedder.createEmbeddings).not.toHaveBeenCalled() + expect(mockCacheManager.updateHash).not.toHaveBeenCalled() + }) + + it("fires no batch-finished or progress events for a batch whose session was stopped", async () => { + watcherWithRegistry() + const controller = new AbortController() + await fileWatcher.initialize(controller.signal) + await mockOnDidChange({ fsPath: path }) + const finished = vi.fn() + const progress = vi.fn() + fileWatcher.onDidFinishBatchProcessing(finished) + fileWatcher.onBatchProgressUpdate(progress) + controller.abort() + await flushBatch() + expect(finished).not.toHaveBeenCalled() + expect(progress).not.toHaveBeenCalledWith(expect.objectContaining({ processedInBatch: 1 })) + }) + + it("fires no processed-file progress when the session stops while a chunk is in flight", async () => { + watcherWithRegistry() + const controller = new AbortController() + let releasePrepare: () => void = () => {} + vi.spyOn(FilePreparation.prototype, "prepareFile").mockImplementationOnce( + () => + new Promise((resolve) => { + releasePrepare = () => + resolve({ path, status: "local_error", activity: "read", error: new Error("x") }) + }), + ) + await fileWatcher.initialize(controller.signal) + const progress = vi.fn() + fileWatcher.onBatchProgressUpdate(progress) + await mockOnDidChange({ fsPath: path }) + await flushBatch() + expect(progress).toHaveBeenCalledWith(expect.objectContaining({ processedInBatch: 0, totalInBatch: 1 })) + controller.abort() + releasePrepare() + await vi.advanceTimersByTimeAsync(0) + expect(progress).not.toHaveBeenCalledWith(expect.objectContaining({ processedInBatch: 1 })) + }) + }) + describe("dispose", () => { it("should dispose of the watcher when disposed", async () => { await fileWatcher.initialize() @@ -365,5 +589,16 @@ describe("FileWatcher", () => { expect(mockWatcher.dispose).toHaveBeenCalled() }) + + it("keeps batch events working after dispose and re-initialize (restart)", async () => { + await fileWatcher.initialize() + fileWatcher.dispose() + await fileWatcher.initialize() + const batchPromise = waitForNextBatch() + await mockOnDidChange({ fsPath: "/mock/workspace/src/file.ts" }) + await flushBatch() + const summary = await batchPromise + expect(summary.processedFiles).toEqual([{ path: "/mock/workspace/src/file.ts", status: "success" }]) + }) }) }) diff --git a/src/services/code-index/processors/__tests__/parser.spec.ts b/src/services/code-index/processors/__tests__/parser.spec.ts index e17128d0c5..c5dbb908ec 100644 --- a/src/services/code-index/processors/__tests__/parser.spec.ts +++ b/src/services/code-index/processors/__tests__/parser.spec.ts @@ -1,6 +1,6 @@ // npx vitest services/code-index/processors/__tests__/parser.spec.ts -import { CodeParser, codeParser } from "../parser" +import { CodeParser, codeParser, ParserLoadError } from "../parser" import { loadRequiredLanguageParsers } from "../../../tree-sitter/languageParser" import { parseMarkdown } from "../../../tree-sitter/markdownParser" import { readFile } from "fs/promises" @@ -165,16 +165,20 @@ describe("CodeParser", () => { expect(result).toBeDefined() }) - it("should handle parser load errors", async () => { - ;(loadRequiredLanguageParsers as any).mockRejectedValue(new Error("Load failed")) - const result = await parser["parseContent"]("test.js", "const test = 123", "hash") - expect(result).toEqual([]) - }) - - it("should return empty array when no parser is available", async () => { - ;(loadRequiredLanguageParsers as any).mockResolvedValue({} as any) + it.each([ + [ + "a rejected load", + () => vi.mocked(loadRequiredLanguageParsers).mockRejectedValueOnce(new Error("Load failed")), + ], + ["no parser for the extension", () => vi.mocked(loadRequiredLanguageParsers).mockResolvedValueOnce({})], + ])("CI-PA-01: %s throws ParserLoadError and the next call reloads", async (_label, failLoad) => { + failLoad() + await expect(parser["parseContent"]("test.js", "const test = 123", "hash")).rejects.toBeInstanceOf( + ParserLoadError, + ) const result = await parser["parseContent"]("test.js", "const test = 123", "hash") - expect(result).toEqual([]) + expect(Array.isArray(result)).toBe(true) + expect(loadRequiredLanguageParsers).toHaveBeenCalledTimes(2) }) }) @@ -318,10 +322,27 @@ describe("CodeParser", () => { describe("singleton instance", () => { it("should maintain parser state across calls", async () => { - const result1 = await codeParser.parseFile("test.js", { content: "const a = 1" }) - const result2 = await codeParser.parseFile("test.js", { content: "const b = 2" }) - expect(result1).toBeDefined() - expect(result2).toBeDefined() + // Capture each parsed root, so the result reflects the input rather than an earlier fixed capture. + const echoRoot = (node: { text: string }) => [ + { + node: { + text: node.text, + startPosition: { row: 0 }, + endPosition: { row: 0 }, + type: "function_declaration", + childForFieldName: vi.fn().mockReturnValue(null), + children: [], + }, + name: "definition.function", + }, + ] + mockLanguageParser.js.query.captures.mockImplementationOnce(echoRoot).mockImplementationOnce(echoRoot) + const first = "const firstValue = 'singleton parser first input with enough text'" + const second = "const secondValue = 'singleton parser second input with enough text'" + const result1 = await codeParser.parseFile("test.js", { content: first }) + const result2 = await codeParser.parseFile("test.js", { content: second }) + expect(result1.map((block) => block.content)).toEqual([first]) + expect(result2.map((block) => block.content)).toEqual([second]) }) }) diff --git a/src/services/code-index/processors/__tests__/processor-factories.spec.ts b/src/services/code-index/processors/__tests__/processor-factories.spec.ts index 1b222fb997..776dd7e483 100644 --- a/src/services/code-index/processors/__tests__/processor-factories.spec.ts +++ b/src/services/code-index/processors/__tests__/processor-factories.spec.ts @@ -18,6 +18,8 @@ import { getEmbeddingBatchSize } from "../get-embedding-batch-size" import { codeParser } from "../parser" import { DirectoryScanner } from "../scanner" import { FileWatcher } from "../file-watcher" +import { FileFailureRegistry } from "../../file-failure-registry" +import { ProviderPacerRegistry } from "../../provider-pacer" vi.mock("vscode", () => ({ workspace: { getConfiguration: vi.fn(() => ({ get: vi.fn() })) }, @@ -36,14 +38,17 @@ describe("processor factories", () => { function dependencies() { const context = makeExtensionContext() + const pacers = new ProviderPacerRegistry() return { context, workspacePath: "/workspace", cacheManager: new CacheManager(context, "/workspace"), embedder: new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }), - vectorStore: new QdrantVectorStore("/workspace", "http://localhost:6333", 512), + vectorStore: new QdrantVectorStore("/workspace", "http://localhost:6333", 512, undefined, pacers), parser: codeParser, ignoreInstance: ignore(), + failureRegistry: new FileFailureRegistry(), + pacers, } } @@ -67,6 +72,8 @@ describe("processor factories", () => { options.cacheManager, options.ignoreInstance, batchSize, + options.failureRegistry, + options.pacers, ) expect(FileWatcher).toHaveBeenCalledWith( options.workspacePath, @@ -77,6 +84,8 @@ describe("processor factories", () => { options.ignoreInstance, undefined, batchSize, + options.failureRegistry, + options.pacers, ) expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name) expect(configuration.get).toHaveBeenCalledWith("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) @@ -148,13 +157,19 @@ describe("processor factories", () => { vi.mocked(configManager.getConfig).mockReturnValue(config) const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create").mockReturnValue(options.embedder) const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create").mockReturnValue(options.vectorStore) - const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + const factory = new CodeIndexServiceFactory( + configManager, + options.workspacePath, + options.cacheManager, + options.failureRegistry, + options.pacers, + ) const services = factory.createServices(options.context, watcherCache, options.ignoreInstance) expect(configManager.getConfig).toHaveBeenCalledTimes(1) - expect(createEmbedder).toHaveBeenCalledExactlyOnceWith(config) - expect(createVectorStore).toHaveBeenCalledExactlyOnceWith(config, options.workspacePath) + expect(createEmbedder).toHaveBeenCalledExactlyOnceWith(config, options.pacers) + expect(createVectorStore).toHaveBeenCalledExactlyOnceWith(config, options.workspacePath, options.pacers) expect(services).toEqual({ embedder: options.embedder, vectorStore: options.vectorStore, @@ -165,6 +180,8 @@ describe("processor factories", () => { expect(vi.mocked(DirectoryScanner).mock.calls[0][3]).toBe(options.cacheManager) expect(vi.mocked(FileWatcher).mock.calls[0][2]).toBe(watcherCache) expect(vi.mocked(FileWatcher).mock.calls[0][0]).toBe(options.workspacePath) + expect(vi.mocked(DirectoryScanner).mock.calls[0].slice(6)).toEqual([options.failureRegistry, options.pacers]) + expect(vi.mocked(FileWatcher).mock.calls[0].slice(8)).toEqual([options.failureRegistry, options.pacers]) }) it("does not create services when indexing is not configured", () => { @@ -173,7 +190,13 @@ describe("processor factories", () => { Object.defineProperty(configManager, "isFeatureConfigured", { value: false }) const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create") const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create") - const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + const factory = new CodeIndexServiceFactory( + configManager, + options.workspacePath, + options.cacheManager, + options.failureRegistry, + options.pacers, + ) expect(() => factory.createServices(options.context, options.cacheManager, options.ignoreInstance)).toThrow( "serviceFactory.codeIndexingNotConfigured", diff --git a/src/services/code-index/processors/__tests__/scanner.spec.ts b/src/services/code-index/processors/__tests__/scanner.spec.ts index 72fefd676d..867874c985 100644 --- a/src/services/code-index/processors/__tests__/scanner.spec.ts +++ b/src/services/code-index/processors/__tests__/scanner.spec.ts @@ -1,7 +1,13 @@ // npx vitest services/code-index/processors/__tests__/scanner.spec.ts import { DirectoryScanner } from "../scanner" +import { ParserLoadError } from "../parser" +import { FileFailureRegistry } from "../../file-failure-registry" +import { ProviderPacerRegistry } from "../../provider-pacer" import { stat } from "fs/promises" +import { createHash } from "crypto" +import nodePath from "path" +import * as vscode from "vscode" // Mock TelemetryService vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ @@ -78,10 +84,12 @@ describe("DirectoryScanner", () => { beforeEach(async () => { mockEmbedder = { + rateLimitScope: "openai:1", createEmbeddings: vi.fn().mockResolvedValue({ embeddings: [[0.1, 0.2, 0.3]] }), embedderInfo: { name: "mock-embedder", dimensions: 384 }, } mockVectorStore = { + rateLimitScope: "qdrant:1", upsertPoints: vi.fn().mockResolvedValue(undefined), deletePointsByFilePath: vi.fn().mockResolvedValue(undefined), deletePointsByMultipleFilePaths: vi.fn().mockResolvedValue(undefined), @@ -112,6 +120,9 @@ describe("DirectoryScanner", () => { mockCodeParser, mockCacheManager, mockIgnoreInstance, + undefined, + new FileFailureRegistry(), + new ProviderPacerRegistry(), ) // Mock default implementations - create proper Stats object @@ -178,6 +189,9 @@ describe("DirectoryScanner", () => { mockCodeParser, mockCacheManager, mockIgnoreInstance, + undefined, + new FileFailureRegistry(), + new ProviderPacerRegistry(), ) const { listFiles } = await import("../../../glob/list-files") @@ -228,6 +242,8 @@ describe("DirectoryScanner", () => { mockCacheManager, mockIgnoreInstance, 1, + new FileFailureRegistry(), + new ProviderPacerRegistry(), ) const { listFiles } = await import("../../../glob/list-files") vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false]) @@ -299,6 +315,9 @@ describe("DirectoryScanner", () => { mockCodeParser, mockCacheManager, mockIgnoreInstance, + undefined, + new FileFailureRegistry(), + new ProviderPacerRegistry(), ) const { listFiles } = await import("../../../glob/list-files") @@ -442,7 +461,7 @@ describe("DirectoryScanner", () => { expect(result.stats.processed).toBe(0) }) - it("should stop processing batches when signal is aborted mid-scan", async () => { + it("should not write once the signal aborts mid-scan", async () => { const { listFiles } = await import("../../../glob/list-files") vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js"], false]) @@ -467,12 +486,272 @@ describe("DirectoryScanner", () => { return mockBlocks }) - // AbortError should propagate up (the orchestrator handles it in its catch block) - await expect( - scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal), - ).rejects.toThrow("Indexing aborted") + await scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal) + expect(mockEmbedder.createEmbeddings).not.toHaveBeenCalled() + expect(mockVectorStore.upsertPoints).not.toHaveBeenCalled() + expect(mockCacheManager.updateHash).not.toHaveBeenCalled() + }) + + it.each(["stat", "read"] as const)( + "CI-SC-01: a file missing at %s is dropped and its old entries deleted without a failure", + async (stage) => { + const failures = new FileFailureRegistry() + const missingScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/gone.js"], false]) + mockCacheManager.getAllHashes.mockReturnValue({ "test/gone.js": "old-hash" }) + const missing = Object.assign(new Error("gone"), { code: "ENOENT" }) + if (stage === "stat") vi.mocked(stat).mockRejectedValueOnce(missing) + else vi.mocked(vscode.workspace.fs.readFile).mockRejectedValueOnce(missing) + + await missingScanner.scanDirectory("/test") + + expect(mockCodeParser.parseFile).not.toHaveBeenCalled() + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledWith("test/gone.js") + expect(mockCacheManager.deleteHash).toHaveBeenCalledWith("test/gone.js") + expect(failures.list()).toEqual([]) + }, + ) + + it("removes points and the entry of a failed file that has no hash and is gone", async () => { + const failures = new FileFailureRegistry() + const removalScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([[], false]) + const relativePath = nodePath.normalize("src/failed.js") + failures.renew(relativePath, "h") + failures.record(relativePath, "h", "upsert", "down", 3, "exhausted") + + await removalScanner.scanDirectory("/test") + + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledOnce() + const [[removedPath]] = vi.mocked(mockVectorStore.deletePointsByFilePath).mock.calls + expect(nodePath.isAbsolute(removedPath)).toBe(true) + expect(removedPath.endsWith(relativePath)).toBe(true) + expect(failures.list()).toEqual([]) + }) + + function createScannerWithFailures() { + const failures = new FileFailureRegistry() + const failureScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + return { failures, failureScanner } + } + + it.each([ + { truncated: true, removed: false }, + { truncated: false, removed: true }, + ])( + "failure-only file with truncated listing=$truncated is removed=$removed", + async ({ truncated, removed }) => { + const { failures, failureScanner: truncationScanner } = createScannerWithFailures() + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([[], truncated]) + const relativePath = nodePath.normalize("src/unlisted.js") + failures.renew(relativePath, "h") + failures.record(relativePath, "h", "upsert", "down", 3, "exhausted") + + await truncationScanner.scanDirectory("/test") + + expect(vi.mocked(mockVectorStore.deletePointsByFilePath).mock.calls.length).toBe(removed ? 1 : 0) + expect(failures.list()).toHaveLength(removed ? 0 : 1) + }, + ) + + it.each(["stat", "read"] as const)( + "removes a listed failure-only file missing at %s even when the listing is truncated", + async (stage) => { + const { failures, failureScanner: truncationScanner } = createScannerWithFailures() + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/vanished.js"], true]) + const missing = Object.assign(new Error("gone"), { code: "ENOENT" }) + if (stage === "stat") vi.mocked(stat).mockRejectedValueOnce(missing) + else vi.mocked(vscode.workspace.fs.readFile).mockRejectedValueOnce(missing) + const relativePath = nodePath.normalize("test/vanished.js") + failures.renew(relativePath, "h") + failures.record(relativePath, "h", "upsert", "down", 3, "exhausted") + + await truncationScanner.scanDirectory("/test") + + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledOnce() + expect(failures.list()).toEqual([]) + }, + ) + + it("removes a listed oversized failure-only file even when the listing is truncated", async () => { + const { failures, failureScanner: truncationScanner } = createScannerWithFailures() + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/big.js"], true]) + vi.mocked(stat).mockResolvedValueOnce({ ...mockStats, size: 2 * 1024 * 1024 }) + const relativePath = nodePath.normalize("test/big.js") + failures.renew(relativePath, "h") + failures.record(relativePath, "h", "upsert", "down", 3, "exhausted") + + await truncationScanner.scanDirectory("/test") + + expect(mockVectorStore.deletePointsByFilePath).toHaveBeenCalledOnce() + expect(failures.list()).toEqual([]) }) + it("drains queued writes and rejects with the abort reason while a parse is pending", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js"], false]) + const controller = new AbortController() + const abortError = new Error("aborted write") + let releaseSecondParse: () => void = () => {} + const secondParse = new Promise((resolve) => { + releaseSecondParse = resolve + }) + const block = { + file_path: "test/file1.js", + content: "function hello() {}", + start_line: 1, + end_line: 1, + identifier: "hello", + type: "function", + fileHash: "hash1", + segmentHash: "seg-hash-1", + } + vi.mocked(mockCodeParser.parseFile) + .mockResolvedValueOnce([block]) + .mockImplementationOnce(async () => { + await secondParse + return [] + }) + vi.mocked(mockEmbedder.createEmbeddings).mockImplementationOnce(async () => { + controller.abort() + setTimeout(releaseSecondParse, 0) + throw abortError + }) + + const rejection = await scanner + .scanDirectory("/test", undefined, undefined, undefined, controller.signal) + .then( + () => undefined, + (error: unknown) => error, + ) + + // Read the reason only after the scan settles; it is undefined until abort() runs. + expect(controller.signal.aborted).toBe(true) + expect(rejection).toBeInstanceOf(DOMException) + expect(rejection).toBe(controller.signal.reason) + }) + + it("records a non-missing read error as a read failure", async () => { + const failures = new FileFailureRegistry() + const readScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/denied.js"], false]) + vi.mocked(stat).mockRejectedValueOnce(Object.assign(new Error("denied"), { code: "EACCES" })) + + await readScanner.scanDirectory("/test") + + expect(failures.list()).toEqual([expect.objectContaining({ activity: "read", reason: "denied" })]) + expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled() + }) + + it("clears a read failure once the read succeeds with unchanged content", async () => { + const failures = new FileFailureRegistry() + const recoveryScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/cached.js"], false]) + mockCacheManager.getHash.mockReturnValue(createHash("sha256").update("test content").digest("hex")) + vi.mocked(stat).mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) + + await recoveryScanner.scanDirectory("/test") + expect(failures.list()).toEqual([expect.objectContaining({ activity: "read" })]) + + const result = await recoveryScanner.scanDirectory("/test") + expect(result.stats.skipped).toBe(1) + expect(failures.list()).toEqual([]) + }) + + it.each([ + ["parser load", new ParserLoadError(".js", new Error("wasm"))], + ["parse", new Error("syntax")], + ])( + "CI-SC-02: a %s failure is recorded, the hash is not stored and other files continue", + async (_label, error) => { + const failures = new FileFailureRegistry() + const parseScanner = new DirectoryScanner( + mockEmbedder, + mockVectorStore, + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + undefined, + failures, + new ProviderPacerRegistry(), + ) + const block = { + file_path: "test/file2.js", + content: "ok", + start_line: 1, + end_line: 1, + identifier: null, + type: "function", + fileHash: "h", + segmentHash: "s", + } + mockCodeParser.parseFile.mockImplementation(async (filePath: string) => { + if (filePath === "test/file1.js") throw error + return [block] + }) + + await parseScanner.scanDirectory("/test") + + expect(failures.list()).toEqual([ + expect.objectContaining({ path: nodePath.normalize("test/file1.js"), activity: "parse" }), + ]) + expect(mockCacheManager.updateHash).not.toHaveBeenCalledWith("test/file1.js", expect.anything()) + expect(mockCacheManager.updateHash).toHaveBeenCalledWith("test/file2.js", expect.any(String)) + }, + ) + it("should not process deleted files when signal is aborted", async () => { const { listFiles } = await import("../../../glob/list-files") vi.mocked(listFiles).mockResolvedValue([[], false]) diff --git a/src/services/code-index/processors/directory-scanner-factory.ts b/src/services/code-index/processors/directory-scanner-factory.ts index 099aa6d261..9efce99806 100644 --- a/src/services/code-index/processors/directory-scanner-factory.ts +++ b/src/services/code-index/processors/directory-scanner-factory.ts @@ -9,6 +9,8 @@ export class DirectoryScannerFactory implements IDirectoryScannerFactory { parser, cacheManager, ignoreInstance, + failureRegistry, + pacers, }: DirectoryScannerFactoryOptions): DirectoryScanner { return new DirectoryScanner( embedder, @@ -17,6 +19,8 @@ export class DirectoryScannerFactory implements IDirectoryScannerFactory { cacheManager, ignoreInstance, getEmbeddingBatchSize(), + failureRegistry, + pacers, ) } } diff --git a/src/services/code-index/processors/file-preparation-dependencies.ts b/src/services/code-index/processors/file-preparation-dependencies.ts index 001da428d7..9e11635bec 100644 --- a/src/services/code-index/processors/file-preparation-dependencies.ts +++ b/src/services/code-index/processors/file-preparation-dependencies.ts @@ -2,7 +2,7 @@ import type { Ignore } from "ignore" import type { FileSystem } from "vscode" import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" import type { CacheManager } from "../cache-manager" -import type { ICodeParser, IEmbedder } from "../interfaces" +import type { ICodeParser } from "../interfaces" export interface FilePreparationDependencies { workspacePath: string @@ -11,5 +11,4 @@ export interface FilePreparationDependencies { fileSystem: Pick cacheManager: Pick parser: ICodeParser - embedder?: Pick } diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index a99ff6b56a..50d8858b65 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -1,68 +1,67 @@ import { createHash } from "crypto" import { Uri } from "vscode" -import { v5 as uuidv5 } from "uuid" -import type { CodeBlock, FileProcessingResult, PointStruct } from "../interfaces" +import type { FileProcessingResult } from "../interfaces" import type { FilePreparationDependencies } from "./file-preparation-dependencies" -import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../constants" -import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" +import { MAX_FILE_SIZE_BYTES } from "../constants" +import { generateRelativeFilePath } from "../shared/get-relative-path" +import { readSource, SOURCE_MISSING } from "../shared/indexing-errors" import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" -/** Prepares one file for batching without writing points or mutating the hash cache. */ +export const SKIP_UNCHANGED = "File has not changed" +export const SKIP_IGNORED_DIRECTORY = "File is in an ignored directory" +export const SKIP_IGNORED = "File is ignored by .rooignore or .gitignore" +export const SKIP_TOO_LARGE = "File is too large" + +/** Prepares one file for batching without embedding, writing points or mutating the hash cache. */ export class FilePreparation { constructor(private readonly dependencies: FilePreparationDependencies) {} public async prepareFile(filePath: string): Promise { const dependencies = this.dependencies + let content: string try { const skipReason = await this.getSkipReason(filePath) - if (skipReason !== undefined) { - return this.skippedResult(filePath, skipReason) - } + if (skipReason === SOURCE_MISSING) return { path: filePath, status: "missing" } + if (skipReason !== undefined) return this.skippedResult(filePath, skipReason) - const fileContent = await dependencies.fileSystem.readFile(Uri.file(filePath)) - const content = Buffer.from(fileContent).toString("utf-8") - const newHash = createHash("sha256").update(content).digest("hex") + const fileContent = await readSource(dependencies.fileSystem.readFile(Uri.file(filePath))) + if (fileContent === SOURCE_MISSING) return { path: filePath, status: "missing" } + content = Buffer.from(fileContent).toString("utf-8") + } catch (error) { + return { path: filePath, status: "local_error", activity: "read", error: error as Error } + } + try { + const newHash = createHash("sha256").update(content).digest("hex") if (dependencies.cacheManager.getHash(filePath) === newHash) { - return this.skippedResult(filePath, "File has not changed") + return this.skippedResult(filePath, SKIP_UNCHANGED) } - const blocks = await dependencies.parser.parseFile(filePath, { content, fileHash: newHash }) - const pointsToUpsert = await this.preparePoints(blocks) - - return { - path: filePath, - status: "processed_for_batching", - newHash, - pointsToUpsert, - } + return { path: filePath, status: "processed_for_batching", newHash, blocks } } catch (error) { - return { - path: filePath, - status: "local_error", - error: error as Error, - } + return { path: filePath, status: "local_error", activity: "parse", error: error as Error } } } - private async getSkipReason(filePath: string): Promise { + private async getSkipReason(filePath: string): Promise { const dependencies = this.dependencies // Use relative paths so ignored directories outside the workspace do not exclude the file. const relativeFilePath = generateRelativeFilePath(filePath, dependencies.workspacePath) if (isPathInIgnoredDirectory(relativeFilePath)) { - return "File is in an ignored directory" + return SKIP_IGNORED_DIRECTORY } if ( !dependencies.ignoreController.validateAccess(filePath) || dependencies.ignoreInstance?.ignores(relativeFilePath) ) { - return "File is ignored by .rooignore or .gitignore" + return SKIP_IGNORED } - const fileStat = await dependencies.fileSystem.stat(Uri.file(filePath)) + const fileStat = await readSource(dependencies.fileSystem.stat(Uri.file(filePath))) + if (fileStat === SOURCE_MISSING) return SOURCE_MISSING if (fileStat.size > MAX_FILE_SIZE_BYTES) { - return "File is too large" + return SKIP_TOO_LARGE } return undefined } @@ -70,31 +69,4 @@ export class FilePreparation { private skippedResult(filePath: string, reason: string): FileProcessingResult { return { path: filePath, status: "skipped", reason } } - - private async preparePoints(blocks: CodeBlock[]): Promise { - const dependencies = this.dependencies - if (!dependencies.embedder || blocks.length === 0) { - return [] - } - - const texts = blocks.map((block) => block.content) - const { embeddings } = await dependencies.embedder.createEmbeddings(texts) - - return blocks.map((block, index) => { - const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, dependencies.workspacePath) - const stableName = `${normalizedAbsolutePath}:${block.start_line}` - const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE) - - return { - id: pointId, - vector: embeddings[index], - payload: { - filePath: generateRelativeFilePath(normalizedAbsolutePath, dependencies.workspacePath), - codeChunk: block.content, - startLine: block.start_line, - endLine: block.end_line, - }, - } - }) - } } diff --git a/src/services/code-index/processors/file-watcher-factory.ts b/src/services/code-index/processors/file-watcher-factory.ts index 2f4cc227b6..d76e66d6c4 100644 --- a/src/services/code-index/processors/file-watcher-factory.ts +++ b/src/services/code-index/processors/file-watcher-factory.ts @@ -12,6 +12,8 @@ export class FileWatcherFactory implements IFileWatcherFactory { vectorStore, ignoreInstance, rooIgnoreController, + failureRegistry, + pacers, }: FileWatcherFactoryOptions): IFileWatcher { return new FileWatcher( workspacePath, @@ -22,6 +24,8 @@ export class FileWatcherFactory implements IFileWatcherFactory { ignoreInstance, rooIgnoreController, getEmbeddingBatchSize(), + failureRegistry, + pacers, ) } } diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index 0a0514a891..ff45562236 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -1,33 +1,30 @@ import * as vscode from "vscode" -import { BATCH_SEGMENT_THRESHOLD, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS } from "../constants" +import { BATCH_SEGMENT_THRESHOLD } from "../constants" import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" import { Ignore } from "ignore" import { scannerExtensions } from "../shared/supported-extensions" -import { - IFileWatcher, - FileProcessingResult, - IEmbedder, - IVectorStore, - PointStruct, - BatchProcessingSummary, -} from "../interfaces" +import { IFileWatcher, FileProcessingResult, IEmbedder, IVectorStore, BatchProcessingSummary } from "../interfaces" import { codeParser } from "./parser" -import { FilePreparation } from "./file-preparation" +import { FilePreparation, SKIP_UNCHANGED } from "./file-preparation" import { CacheManager } from "../cache-manager" -import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName } from "@roo-code/types" -import { sanitizeErrorMessage } from "../shared/validation-helpers" import { Package } from "../../../shared/package" +import { type FileFailureRegistry, UNKNOWN_REVISION } from "../file-failure-registry" +import type { ProviderPacerRegistry } from "../provider-pacer" +import { FileIndexWriter } from "../file-index-writer" +import { generateRelativeFilePath } from "../shared/get-relative-path" /** * Implementation of the file watcher interface */ export class FileWatcher implements IFileWatcher { private readonly filePreparation: FilePreparation + private writer?: FileIndexWriter + private signal?: AbortSignal private ignoreInstance?: Ignore private fileWatcher?: vscode.FileSystemWatcher private ignoreController: RooIgnoreController private accumulatedEvents: Map = new Map() + private batchQueue: Promise = Promise.resolve() private batchProcessDebounceTimer?: NodeJS.Timeout private readonly BATCH_DEBOUNCE_DELAY_MS = 500 private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10 @@ -68,11 +65,13 @@ export class FileWatcher implements IFileWatcher { private workspacePath: string, _context: vscode.ExtensionContext, private readonly cacheManager: CacheManager, - private embedder?: IEmbedder, - private vectorStore?: IVectorStore, - ignoreInstance?: Ignore, - ignoreController?: RooIgnoreController, - batchSegmentThreshold?: number, + private embedder: IEmbedder | undefined, + private vectorStore: IVectorStore | undefined, + ignoreInstance: Ignore | undefined, + ignoreController: RooIgnoreController | undefined, + batchSegmentThreshold: number | undefined, + private readonly failures: FileFailureRegistry, + private readonly pacers: ProviderPacerRegistry, ) { this.ignoreController = ignoreController || new RooIgnoreController(workspacePath) if (ignoreInstance) { @@ -85,7 +84,6 @@ export class FileWatcher implements IFileWatcher { fileSystem: vscode.workspace.fs, cacheManager: this.cacheManager, parser: codeParser, - embedder: this.embedder, }) // Get the configurable batch size from VSCode settings, fallback to default // If not provided in constructor, try to get from VSCode settings @@ -101,12 +99,24 @@ export class FileWatcher implements IFileWatcher { this.batchSegmentThreshold = BATCH_SEGMENT_THRESHOLD } } + if (this.embedder && this.vectorStore) { + this.writer = new FileIndexWriter( + this.embedder, + this.vectorStore, + this.cacheManager, + this.failures, + this.pacers, + this.workspacePath, + this.batchSegmentThreshold, + ) + } } /** - * Initializes the file watcher + * Initializes the file watcher; `signal` is the indexing session's abort signal. */ - async initialize(): Promise { + async initialize(signal?: AbortSignal): Promise { + this.signal = signal // Create file watcher const filePattern = new vscode.RelativePattern( this.workspacePath, @@ -121,16 +131,15 @@ export class FileWatcher implements IFileWatcher { } /** - * Disposes the file watcher + * Stops watching. The event emitters stay usable because the orchestrator reuses this watcher on restart; + * it disposes its own subscriptions. */ dispose(): void { this.fileWatcher?.dispose() + this.fileWatcher = undefined if (this.batchProcessDebounceTimer) { clearTimeout(this.batchProcessDebounceTimer) } - this._onDidStartBatchProcessing.dispose() - this._onBatchProgressUpdate.dispose() - this._onDidFinishBatchProcessing.dispose() this.accumulatedEvents.clear() } @@ -185,320 +194,105 @@ export class FileWatcher implements IFileWatcher { const filePathsInBatch = Array.from(eventsToProcess.keys()) this._onDidStartBatchProcessing.fire(filePathsInBatch) - await this.processBatch(eventsToProcess) + // Batches run one at a time so a later change or delete of a path cannot finish before an earlier one. + // Each batch keeps its own session signal, so a restart's new signal never authorizes a stale write. + const signal = this.signal + this.batchQueue = this.batchQueue + .then(() => this.processBatch(eventsToProcess, signal)) + .catch((error) => console.error("[FileWatcher] Batch processing failed:", error)) + await this.batchQueue } - /** - * Processes a batch of accumulated events - * @param eventsToProcess Map of events to process - */ - private async _handleBatchDeletions( - batchResults: FileProcessingResult[], - processedCountInBatch: number, - totalFilesInBatch: number, - pathsToExplicitlyDelete: string[], - filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }>, - ): Promise<{ overallBatchError?: Error; clearedPaths: Set; processedCount: number }> { - let overallBatchError: Error | undefined - const allPathsToClearFromDB = new Set(pathsToExplicitlyDelete) - - for (const fileDetail of filesToUpsertDetails) { - if (fileDetail.originalType === "change") { - allPathsToClearFromDB.add(fileDetail.path) - } - } - - if (allPathsToClearFromDB.size > 0 && this.vectorStore) { - try { - await this.vectorStore.deletePointsByMultipleFilePaths(Array.from(allPathsToClearFromDB)) - - for (const path of pathsToExplicitlyDelete) { - this.cacheManager.deleteHash(path) - batchResults.push({ path, status: "success" }) - processedCountInBatch++ - this._onBatchProgressUpdate.fire({ - processedInBatch: processedCountInBatch, - totalInBatch: totalFilesInBatch, - currentFile: path, - }) - } - } catch (error: any) { - const errorStatus = error?.status || error?.response?.status || error?.statusCode - const errorMessage = error instanceof Error ? error.message : String(error) - - // Log telemetry for deletion error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(errorMessage), - location: "deletePointsByMultipleFilePaths", - errorType: "deletion_error", - errorStatus: errorStatus, - }) - - // Mark all paths as error - overallBatchError = error as Error - for (const path of pathsToExplicitlyDelete) { - batchResults.push({ path, status: "error", error: error as Error }) - processedCountInBatch++ - this._onBatchProgressUpdate.fire({ - processedInBatch: processedCountInBatch, - totalInBatch: totalFilesInBatch, - currentFile: path, - }) - } - } - } - - return { overallBatchError, clearedPaths: allPathsToClearFromDB, processedCount: processedCountInBatch } + private async removeFile(filePath: string, signal: AbortSignal | undefined): Promise { + if (!this.writer) return { path: filePath, status: "success" } + const removed = await this.writer.remove(filePath, signal) + return removed + ? { path: filePath, status: "success" } + : { path: filePath, status: "error", error: new Error(`Failed to remove ${filePath} from the index`) } } - private async _processFilesAndPrepareUpserts( - filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }>, - batchResults: FileProcessingResult[], - processedCountInBatch: number, - totalFilesInBatch: number, - pathsToExplicitlyDelete: string[], - ): Promise<{ - pointsForBatchUpsert: PointStruct[] - successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }> - processedCount: number - }> { - const pointsForBatchUpsert: PointStruct[] = [] - 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) - - const chunkProcessingPromises = chunkToProcess.map(async (fileDetail) => { - this._onBatchProgressUpdate.fire({ - processedInBatch: processedCountInBatch, - totalInBatch: totalFilesInBatch, - currentFile: fileDetail.path, - }) - try { - const result = await this.processFile(fileDetail.path) - return { path: fileDetail.path, result: result, error: undefined } - } catch (e) { - const error = e as Error - console.error(`[FileWatcher] Unhandled exception processing file ${fileDetail.path}:`, e) - return { path: fileDetail.path, result: undefined, error: error } - } - }) - - const settledChunkResults = await Promise.allSettled(chunkProcessingPromises) - - for (const settledResult of settledChunkResults) { - let resultPath: string | undefined - - if (settledResult.status === "fulfilled") { - const { path, result, error: directError } = settledResult.value - resultPath = path - - if (directError) { - batchResults.push({ path, status: "error", error: directError }) - } else if (result) { - if (result.status === "skipped" || result.status === "local_error") { - batchResults.push(result) - } else if (result.status === "processed_for_batching" && result.pointsToUpsert) { - pointsForBatchUpsert.push(...result.pointsToUpsert) - if (result.path && result.newHash) { - successfullyProcessedForUpsert.push({ path: result.path, newHash: result.newHash }) - } else if (result.path && !result.newHash) { - successfullyProcessedForUpsert.push({ path: result.path }) - } - } else { - batchResults.push({ - path, - status: "error", - error: new Error( - `Unexpected result status from processFile: ${result.status} for file ${path}`, - ), - }) - } - } else { - batchResults.push({ - path, - status: "error", - error: new Error(`Fulfilled promise with no result or error for file ${path}`), - }) - } - } else { - const error = settledResult.reason as Error - const rejectedPath = (settledResult.reason as any)?.path || "unknown" - console.error("[FileWatcher] A file processing promise was rejected:", settledResult.reason) - batchResults.push({ - path: rejectedPath, - status: "error", - error: error, - }) - } - - if (!pathsToExplicitlyDelete.includes(resultPath || "")) { - processedCountInBatch++ - } - this._onBatchProgressUpdate.fire({ - processedInBatch: processedCountInBatch, - totalInBatch: totalFilesInBatch, - currentFile: resultPath, - }) + private async indexFile(filePath: string, signal: AbortSignal | undefined): Promise { + const result = await this.processFile(filePath) + if (result.status === "missing") return this.removeFile(filePath, signal) + if (result.status === "local_error") { + if (!signal?.aborted) { + const relativePath = generateRelativeFilePath(filePath, this.workspacePath) + const revision = result.newHash ?? UNKNOWN_REVISION + const reason = result.error instanceof Error ? result.error.message : String(result.error) + this.failures.renew(relativePath, revision) + this.failures.record(relativePath, revision, result.activity ?? "read", reason, 1, "exhausted") } + return result } - - return { - pointsForBatchUpsert, - successfullyProcessedForUpsert, - processedCount: processedCountInBatch, - } - } - - private async _executeBatchUpsertOperations( - pointsForBatchUpsert: PointStruct[], - successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }>, - batchResults: FileProcessingResult[], - overallBatchError?: Error, - ): Promise { - if (pointsForBatchUpsert.length > 0 && this.vectorStore && !overallBatchError) { - try { - for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) { - const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold) - let retryCount = 0 - let upsertError: Error | undefined - - while (retryCount < MAX_BATCH_RETRIES) { - try { - await this.vectorStore.upsertPoints(batch) - break - } catch (error) { - upsertError = error as Error - retryCount++ - if (retryCount === MAX_BATCH_RETRIES) { - // Log telemetry for upsert failure - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(upsertError.message), - location: "upsertPoints", - errorType: "upsert_retry_exhausted", - retryCount: MAX_BATCH_RETRIES, - }) - throw new Error( - `Failed to upsert batch after ${MAX_BATCH_RETRIES} retries: ${upsertError.message}`, - ) - } - await new Promise((resolve) => - setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1)), - ) - } - } - } - - for (const { path, newHash } of successfullyProcessedForUpsert) { - if (newHash) { - this.cacheManager.updateHash(path, newHash) - } - batchResults.push({ path, status: "success" }) - } - } catch (error) { - const err = error as Error - overallBatchError = overallBatchError || err - // Log telemetry for batch upsert error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(err.message), - location: "executeBatchUpsertOperations", - errorType: "batch_upsert_error", - affectedFiles: successfullyProcessedForUpsert.length, - }) - for (const { path } of successfullyProcessedForUpsert) { - batchResults.push({ path, status: "error", error: err }) - } - } - } else if (overallBatchError && pointsForBatchUpsert.length > 0) { - for (const { path } of successfullyProcessedForUpsert) { - batchResults.push({ path, status: "error", error: overallBatchError }) + if (result.status === "skipped") { + if (result.reason === SKIP_UNCHANGED) { + // Content matches the stored hash, so the file is indexed; an earlier read failure is stale. + if (!signal?.aborted) this.failures.clear(generateRelativeFilePath(filePath, this.workspacePath)) + return result } + // A file that became ignored or too large must not keep its old points; a failure entry + // without a hash means a failed first write may have left partial points. + const relativePath = generateRelativeFilePath(filePath, this.workspacePath) + const mayHavePoints = + this.cacheManager.getHash(filePath) !== undefined || + this.failures.list().some((failure) => failure.path === relativePath) + if (mayHavePoints) return this.removeFile(filePath, signal) + return result } - - return overallBatchError + if (result.status !== "processed_for_batching" || !result.newHash || !this.writer) return result + const indexed = await this.writer.replace(filePath, result.newHash, result.blocks ?? [], signal) + return indexed + ? { path: filePath, status: "success" } + : { path: filePath, status: "error", error: new Error(`Failed to index ${filePath}`) } } private async processBatch( eventsToProcess: Map, + signal: AbortSignal | undefined, ): Promise { const batchResults: FileProcessingResult[] = [] - let processedCountInBatch = 0 const totalFilesInBatch = eventsToProcess.size - let overallBatchError: Error | undefined - - // Initial progress update + let processedCountInBatch = 0 this._onBatchProgressUpdate.fire({ processedInBatch: 0, totalInBatch: totalFilesInBatch, currentFile: undefined, }) - // Categorize events - const pathsToExplicitlyDelete: string[] = [] - const filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }> = [] - - for (const event of eventsToProcess.values()) { - if (event.type === "delete") { - pathsToExplicitlyDelete.push(event.uri.fsPath) - } else { - filesToUpsertDetails.push({ - path: event.uri.fsPath, - uri: event.uri, - originalType: event.type, + const events = [...eventsToProcess.values()] + for (let offset = 0; offset < events.length; offset += this.FILE_PROCESSING_CONCURRENCY_LIMIT) { + if (signal?.aborted) break + const chunk = events.slice(offset, offset + this.FILE_PROCESSING_CONCURRENCY_LIMIT) + const results = await Promise.all( + chunk.map(async (event) => { + const filePath = event.uri.fsPath + try { + return event.type === "delete" + ? await this.removeFile(filePath, signal) + : await this.indexFile(filePath, signal) + } catch (error) { + return { path: filePath, status: "error", error: error as Error } satisfies FileProcessingResult + } + }), + ) + if (signal?.aborted) break + for (const result of results) { + batchResults.push(result) + processedCountInBatch++ + this._onBatchProgressUpdate.fire({ + processedInBatch: processedCountInBatch, + totalInBatch: totalFilesInBatch, + currentFile: result.path, }) } } - // Phase 1: Handle deletions - const { overallBatchError: deletionError, processedCount: deletionCount } = await this._handleBatchDeletions( - batchResults, - processedCountInBatch, - totalFilesInBatch, - pathsToExplicitlyDelete, - filesToUpsertDetails, - ) - overallBatchError = deletionError - processedCountInBatch = deletionCount - - // Phase 2: Process files and prepare upserts - const { - pointsForBatchUpsert, - successfullyProcessedForUpsert, - processedCount: upsertCount, - } = await this._processFilesAndPrepareUpserts( - filesToUpsertDetails, - batchResults, - processedCountInBatch, - totalFilesInBatch, - pathsToExplicitlyDelete, - ) - processedCountInBatch = upsertCount - - // Phase 3: Execute batch upsert - overallBatchError = await this._executeBatchUpsertOperations( - pointsForBatchUpsert, - successfullyProcessedForUpsert, - batchResults, - overallBatchError, - ) - - // Finalize - this._onDidFinishBatchProcessing.fire({ - processedFiles: batchResults, - batchError: overallBatchError, - }) - this._onBatchProgressUpdate.fire({ - processedInBatch: totalFilesInBatch, - totalInBatch: totalFilesInBatch, - }) - + // The orchestrator reset its counter on stop; a stale finish would decrement the next session's count. + if (signal?.aborted) return + this._onDidFinishBatchProcessing.fire({ processedFiles: batchResults }) + this._onBatchProgressUpdate.fire({ processedInBatch: totalFilesInBatch, totalInBatch: totalFilesInBatch }) if (this.accumulatedEvents.size === 0) { - this._onBatchProgressUpdate.fire({ - processedInBatch: 0, - totalInBatch: 0, - currentFile: undefined, - }) + this._onBatchProgressUpdate.fire({ processedInBatch: 0, totalInBatch: 0, currentFile: undefined }) } } diff --git a/src/services/code-index/processors/parser.ts b/src/services/code-index/processors/parser.ts index 8445ea1971..95e29105da 100644 --- a/src/services/code-index/processors/parser.ts +++ b/src/services/code-index/processors/parser.ts @@ -11,6 +11,17 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" import { sanitizeErrorMessage } from "../shared/validation-helpers" +/** An eligible file's language parser failed to load; the failure is not cached, so a later call retries. */ +export class ParserLoadError extends Error { + constructor( + readonly extension: string, + cause?: unknown, + ) { + super(`Failed to load the language parser for ${extension} files`, { cause }) + this.name = "ParserLoadError" + } +} + /** * Implementation of the code parser interface */ @@ -119,7 +130,7 @@ export class CodeParser implements ICodeParser { stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, location: "parseContent:loadParser", }) - return [] + throw new ParserLoadError(ext, error) } } else { const loadPromise = loadRequiredLanguageParsers([filePath]) @@ -136,7 +147,7 @@ export class CodeParser implements ICodeParser { stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, location: "parseContent:loadParser", }) - return [] + throw new ParserLoadError(ext, error) } finally { this.pendingLoads.delete(ext) } @@ -146,7 +157,7 @@ export class CodeParser implements ICodeParser { const language = this.loadedParsers[ext] if (!language) { console.warn(`No parser available for file extension: ${ext}`) - return [] + throw new ParserLoadError(ext) } const tree = language.parser.parse(content) diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index 4504e02e9b..ec8360f092 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -9,27 +9,25 @@ import { scannerExtensions } from "../shared/supported-extensions" import * as vscode from "vscode" import { CodeBlock, ICodeParser, IEmbedder, IVectorStore, IDirectoryScanner } from "../interfaces" import { createHash } from "crypto" -import { v5 as uuidv5 } from "uuid" import pLimit from "p-limit" -import { Mutex } from "async-mutex" import { CacheManager } from "../cache-manager" import { t } from "../../../i18n" import { - QDRANT_CODE_BLOCK_NAMESPACE, MAX_FILE_SIZE_BYTES, MAX_LIST_FILES_LIMIT_CODE_INDEX, BATCH_SEGMENT_THRESHOLD, - MAX_BATCH_RETRIES, - INITIAL_RETRY_DELAY_MS, PARSING_CONCURRENCY, BATCH_PROCESSING_CONCURRENCY, - MAX_PENDING_BATCHES, } from "../constants" import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" import { sanitizeErrorMessage } from "../shared/validation-helpers" import { Package } from "../../../shared/package" +import { type FileFailureRegistry, UNKNOWN_REVISION, type FileFailureActivity } from "../file-failure-registry" +import type { ProviderPacerRegistry } from "../provider-pacer" +import { FileIndexWriter } from "../file-index-writer" +import { readSource, SOURCE_MISSING } from "../shared/indexing-errors" export class DirectoryScanner implements IDirectoryScanner { private readonly batchSegmentThreshold: number @@ -40,7 +38,9 @@ export class DirectoryScanner implements IDirectoryScanner { private readonly codeParser: ICodeParser, private readonly cacheManager: CacheManager, private readonly ignoreInstance: Ignore, - batchSegmentThreshold?: number, + batchSegmentThreshold: number | undefined, + private readonly failures: FileFailureRegistry, + private readonly pacers: ProviderPacerRegistry, ) { // Get the configurable batch size from VSCode settings, fallback to default // If not provided in constructor, try to get from VSCode settings @@ -59,12 +59,8 @@ export class DirectoryScanner implements IDirectoryScanner { } /** - * Recursively scans a directory for code blocks in supported files. - * @param directoryPath The directory to scan - * @param rooIgnoreController Optional RooIgnoreController instance for filtering - * @param context VS Code ExtensionContext for cache storage - * @param onError Optional error handler callback - * @returns Promise<{codeBlocks: CodeBlock[], stats: {processed: number, skipped: number}}> Array of parsed code blocks and processing stats + * Recursively scans a directory, indexes new or changed files and removes entries for files that are gone. + * File-level failures are recorded in the failure registry and do not stop the scan. */ public async scanDirectory( directory: string, @@ -76,9 +72,18 @@ export class DirectoryScanner implements IDirectoryScanner { const directoryPath = directory // Capture workspace context at scan start const scanWorkspace = getWorkspacePathForContext(directoryPath) + const writer = new FileIndexWriter( + this.embedder, + this.qdrantClient, + this.cacheManager, + this.failures, + this.pacers, + scanWorkspace, + this.batchSegmentThreshold, + ) // Get all files recursively (handles .gitignore automatically) - const [allPaths, _] = await listFiles(directoryPath, true, MAX_LIST_FILES_LIMIT_CODE_INDEX) + const [allPaths, listingTruncated] = await listFiles(directoryPath, true, MAX_LIST_FILES_LIMIT_CODE_INDEX) // Filter out directories (marked with trailing '/') const filePaths = allPaths.filter((p) => !p.endsWith("/")) @@ -105,414 +110,160 @@ export class DirectoryScanner implements IDirectoryScanner { return scannerExtensions.includes(ext) && !this.ignoreInstance.ignores(relativeFilePath) }) - // Initialize tracking variables const processedFiles = new Set() + const missingFiles = new Set() let processedCount = 0 let skippedCount = 0 - - // Initialize parallel processing tools - const parseLimiter = pLimit(PARSING_CONCURRENCY) // Concurrency for file parsing - const batchLimiter = pLimit(BATCH_PROCESSING_CONCURRENCY) // Concurrency for batch processing - const mutex = new Mutex() - - // Shared batch accumulators (protected by mutex) - let currentBatchBlocks: CodeBlock[] = [] - let currentBatchTexts: string[] = [] - let currentBatchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[] = [] - const activeBatchPromises = new Set>() - let pendingBatchCount = 0 - - // Initialize block counter let totalBlockCount = 0 - // Process all files in parallel with concurrency control + const parseLimiter = pLimit(PARSING_CONCURRENCY) + const writeLimiter = pLimit(BATCH_PROCESSING_CONCURRENCY) + const writes: Promise[] = [] + let writeFailure: { error: unknown } | undefined + + const recordLocalFailure = ( + filePath: string, + revision: string, + activity: FileFailureActivity, + error: unknown, + ) => { + if (signal?.aborted) return + const relativePath = generateRelativeFilePath(filePath, scanWorkspace) + const reason = error instanceof Error ? error.message : String(error) + this.failures.renew(relativePath, revision) + this.failures.record(relativePath, revision, activity, reason, 1, "exhausted") + this.reportError(error, `scanDirectory:${activity}`, filePath, scanWorkspace, onError) + } + const parsePromises = supportedPaths.map((filePath) => parseLimiter(async () => { - // Check abort signal before processing each file if (signal?.aborted) return + let content: string try { - // Check file size - const stats = await stat(filePath) - if (stats.size > MAX_FILE_SIZE_BYTES) { - skippedCount++ // Skip large files + const stats = await readSource(stat(filePath)) + // A file deleted after listing is a removal: leaving it out lets the removal phase delete it. + if (stats === SOURCE_MISSING) { + missingFiles.add(generateRelativeFilePath(filePath, scanWorkspace)) return } - - // Read file content - const content = await vscode.workspace.fs - .readFile(vscode.Uri.file(filePath)) - .then((buffer) => Buffer.from(buffer).toString("utf-8")) - - // Calculate current hash - const currentFileHash = createHash("sha256").update(content).digest("hex") - processedFiles.add(filePath) - - // Check against cache - const cachedFileHash = this.cacheManager.getHash(filePath) - const isNewFile = !cachedFileHash - if (cachedFileHash === currentFileHash) { - // File is unchanged + if (stats.size > MAX_FILE_SIZE_BYTES) { + // Confirmed ineligible, so a truncated listing may still remove its partial points. + missingFiles.add(generateRelativeFilePath(filePath, scanWorkspace)) skippedCount++ return } + const buffer = await readSource(vscode.workspace.fs.readFile(vscode.Uri.file(filePath))) + if (buffer === SOURCE_MISSING) { + missingFiles.add(generateRelativeFilePath(filePath, scanWorkspace)) + return + } + content = Buffer.from(buffer).toString("utf-8") + } catch (error) { + processedFiles.add(filePath) + recordLocalFailure(filePath, UNKNOWN_REVISION, "read", error) + return + } - // File is new or changed - parse it using the injected parser function - const blocks = await this.codeParser.parseFile(filePath, { content, fileHash: currentFileHash }) - const fileBlockCount = blocks.length - onFileParsed?.(fileBlockCount) - processedCount++ - - // Process embeddings if configured - if (this.embedder && this.qdrantClient && blocks.length > 0) { - // Add to batch accumulators - let addedBlocksFromFile = false - for (const block of blocks) { - const trimmedContent = block.content.trim() - if (trimmedContent) { - const release = await mutex.acquire() - try { - currentBatchBlocks.push(block) - currentBatchTexts.push(trimmedContent) - addedBlocksFromFile = true - - // Check if batch threshold is met - // Check abort signal before dispatching batch - if (signal?.aborted) { - throw new DOMException("Indexing aborted", "AbortError") - } - - if (currentBatchBlocks.length >= this.batchSegmentThreshold) { - // Wait if we've reached the maximum pending batches - while (pendingBatchCount >= MAX_PENDING_BATCHES) { - if (signal?.aborted) { - throw new DOMException("Indexing aborted", "AbortError") - } - await Promise.race(activeBatchPromises) - } - - // Copy current batch data and clear accumulators - const batchBlocks = [...currentBatchBlocks] - const batchTexts = [...currentBatchTexts] - const batchFileInfos = [...currentBatchFileInfos] - currentBatchBlocks = [] - currentBatchTexts = [] - currentBatchFileInfos = [] - - // Increment pending batch count - pendingBatchCount++ - - // Queue batch processing - const batchPromise = batchLimiter(() => - this.processBatch( - batchBlocks, - batchTexts, - batchFileInfos, - scanWorkspace, - onError, - onBlocksIndexed, - ), - ) - activeBatchPromises.add(batchPromise) - - // Clean up completed promises to prevent memory accumulation - const cleanupBatch = () => { - activeBatchPromises.delete(batchPromise) - pendingBatchCount-- - } - void batchPromise.then(cleanupBatch, cleanupBatch) - } - } finally { - release() - } - } - } + const currentFileHash = createHash("sha256").update(content).digest("hex") + processedFiles.add(filePath) + if (this.cacheManager.getHash(filePath) === currentFileHash) { + skippedCount++ + // The stored hash proves the file is indexed; an earlier read failure is stale. + if (!signal?.aborted) this.failures.clear(generateRelativeFilePath(filePath, scanWorkspace)) + return + } - // Add file info once per file (outside the block loop) - if (addedBlocksFromFile) { - const release = await mutex.acquire() - try { - totalBlockCount += fileBlockCount - currentBatchFileInfos.push({ - filePath, - fileHash: currentFileHash, - isNew: isNewFile, - }) - } finally { - release() - } - } - } else { - // Only update hash if not being processed in a batch - await this.cacheManager.updateHash(filePath, currentFileHash) - } + let blocks: CodeBlock[] + try { + blocks = await this.codeParser.parseFile(filePath, { content, fileHash: currentFileHash }) } catch (error) { - // Re-throw AbortError — it's not a file processing error, just a user-initiated stop - if (error instanceof DOMException && error.name === "AbortError") { - throw error - } - console.error(`Error processing file ${filePath} in workspace ${scanWorkspace}:`, error) - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), - stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, - location: "scanDirectory:processFile", - }) - if (onError) { - onError( - error instanceof Error - ? new Error(`${error.message} (Workspace: ${scanWorkspace}, File: ${filePath})`) - : new Error( - t("embeddings:scanner.unknownErrorProcessingFile", { filePath }) + - ` (Workspace: ${scanWorkspace})`, - ), - ) - } + // Covers ParserLoadError: the hash stays unstored so Start Indexing retries the unchanged file. + recordLocalFailure(filePath, currentFileHash, "parse", error) + return } + onFileParsed?.(blocks.length) + processedCount++ + if (!this.embedder || !this.qdrantClient) { + this.cacheManager.updateHash(filePath, currentFileHash) + return + } + totalBlockCount += blocks.length + + // The handler is attached at queue time; parses still pending must not leave a rejection unhandled. + writes.push( + writeLimiter(async () => { + if (signal?.aborted) return + const indexed = await writer.replace(filePath, currentFileHash, blocks, signal) + if (indexed) onBlocksIndexed?.(blocks.filter((block) => block.content.trim()).length) + }).catch((error: unknown) => { + writeFailure ??= { error } + }), + ) }), ) - // Wait for all parsing to complete await Promise.all(parsePromises) + await Promise.all(writes) + if (writeFailure) throw writeFailure.error - // Check abort signal before processing remaining batch if (signal?.aborted) { - return { - stats: { - processed: processedCount, - skipped: skippedCount, - }, - totalBlockCount, - } + return { stats: { processed: processedCount, skipped: skippedCount }, totalBlockCount } } - // Process any remaining items in batch - if (currentBatchBlocks.length > 0) { - const release = await mutex.acquire() - try { - // Copy current batch data and clear accumulators - const batchBlocks = [...currentBatchBlocks] - const batchTexts = [...currentBatchTexts] - const batchFileInfos = [...currentBatchFileInfos] - currentBatchBlocks = [] - currentBatchTexts = [] - currentBatchFileInfos = [] - - // Increment pending batch count for final batch - pendingBatchCount++ - - // Queue final batch processing - const batchPromise = batchLimiter(() => - this.processBatch(batchBlocks, batchTexts, batchFileInfos, scanWorkspace, onError, onBlocksIndexed), - ) - activeBatchPromises.add(batchPromise) - - // Clean up completed promises to prevent memory accumulation - const cleanupBatch = () => { - activeBatchPromises.delete(batchPromise) - pendingBatchCount-- - } - void batchPromise.then(cleanupBatch, cleanupBatch) - } finally { - release() - } + const removalCandidates = new Map() + for (const cachedFilePath of Object.keys(this.cacheManager.getAllHashes())) { + removalCandidates.set(generateRelativeFilePath(cachedFilePath, scanWorkspace), cachedFilePath) } - - // Wait for all batch processing to complete - await Promise.all(activeBatchPromises) - - // Check abort signal before handling deleted files - if (signal?.aborted) { - return { - stats: { - processed: processedCount, - skipped: skippedCount, - }, - totalBlockCount, + // A file whose first write failed has no hash, but its failure entry means partial points may exist. + // A truncated listing cannot prove an unlisted failure-only file is gone; a confirmed-missing one is. + for (const failure of this.failures.list()) { + if (listingTruncated && !missingFiles.has(failure.path)) continue + if (!removalCandidates.has(failure.path)) { + removalCandidates.set(failure.path, generateNormalizedAbsolutePath(failure.path, scanWorkspace)) } } - - // Handle deleted files - const oldHashes = this.cacheManager.getAllHashes() - for (const cachedFilePath of Object.keys(oldHashes)) { - if (!processedFiles.has(cachedFilePath)) { - // File was deleted or is no longer supported/indexed - if (this.qdrantClient) { - try { - await this.qdrantClient.deletePointsByFilePath(cachedFilePath) - await this.cacheManager.deleteHash(cachedFilePath) - } catch (error: any) { - const errorStatus = error?.status || error?.response?.status || error?.statusCode - const errorMessage = error instanceof Error ? error.message : String(error) - - console.error( - `[DirectoryScanner] Failed to delete points for ${cachedFilePath} in workspace ${scanWorkspace}:`, - error, - ) - - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(errorMessage), - stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, - location: "scanDirectory:deleteRemovedFiles", - errorStatus: errorStatus, - }) - - if (onError) { - // Report error to error handler - onError( - error instanceof Error - ? new Error( - `${error.message} (Workspace: ${scanWorkspace}, File: ${cachedFilePath})`, - ) - : new Error( - t("embeddings:scanner.unknownErrorDeletingPoints", { - filePath: cachedFilePath, - }) + ` (Workspace: ${scanWorkspace})`, - ), - ) - } - // Log error and continue processing instead of re-throwing - console.error(`Failed to delete points for removed file: ${cachedFilePath}`, error) - } - } + const processedRelative = new Set( + [...processedFiles].map((filePath) => generateRelativeFilePath(filePath, scanWorkspace)), + ) + for (const [relativePath, removedFilePath] of removalCandidates) { + if (processedRelative.has(relativePath)) continue + if (signal?.aborted) break + const removed = await writer.remove(removedFilePath, signal) + if (!removed && !signal?.aborted) { + this.reportError( + new Error(t("embeddings:scanner.unknownErrorDeletingPoints", { filePath: removedFilePath })), + "scanDirectory:deleteRemovedFiles", + removedFilePath, + scanWorkspace, + onError, + ) } } - return { - stats: { - processed: processedCount, - skipped: skippedCount, - }, - totalBlockCount, - } + return { stats: { processed: processedCount, skipped: skippedCount }, totalBlockCount } } - private async processBatch( - batchBlocks: CodeBlock[], - batchTexts: string[], - batchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[], + private reportError( + error: unknown, + location: string, + filePath: string, scanWorkspace: string, onError?: (error: Error) => void, - onBlocksIndexed?: (indexedCount: number) => void, - ): Promise { - if (batchBlocks.length === 0) return - - let attempts = 0 - let success = false - let lastError: Error | null = null - - while (attempts < MAX_BATCH_RETRIES && !success) { - attempts++ - try { - // --- Deletion Step --- - const uniqueFilePaths = [ - ...new Set( - batchFileInfos - .filter((info) => !info.isNew) // Only modified files (not new) - .map((info) => info.filePath), - ), - ] - if (uniqueFilePaths.length > 0) { - try { - await this.qdrantClient.deletePointsByMultipleFilePaths(uniqueFilePaths) - } catch (deleteError: any) { - const errorStatus = - deleteError?.status || deleteError?.response?.status || deleteError?.statusCode - const errorMessage = deleteError instanceof Error ? deleteError.message : String(deleteError) - - console.error( - `[DirectoryScanner] Failed to delete points for ${uniqueFilePaths.length} files before upsert in workspace ${scanWorkspace}:`, - deleteError, - ) - - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(errorMessage), - stack: - deleteError instanceof Error - ? sanitizeErrorMessage(deleteError.stack || "") - : undefined, - location: "processBatch:deletePointsByMultipleFilePaths", - fileCount: uniqueFilePaths.length, - errorStatus: errorStatus, - }) - - // Re-throw with workspace context - throw new Error( - `Failed to delete points for ${uniqueFilePaths.length} files. Workspace: ${scanWorkspace}. ${errorMessage}`, - { cause: deleteError }, - ) - } - } - // --- End Deletion Step --- - - // Create embeddings for batch - const { embeddings } = await this.embedder.createEmbeddings(batchTexts) - - // Prepare points for Qdrant - const points = batchBlocks.map((block, index) => { - const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, scanWorkspace) - - // Use segmentHash for unique ID generation to handle multiple segments from same line - const pointId = uuidv5(block.segmentHash, QDRANT_CODE_BLOCK_NAMESPACE) - - return { - id: pointId, - vector: embeddings[index], - payload: { - filePath: generateRelativeFilePath(normalizedAbsolutePath, scanWorkspace), - codeChunk: block.content, - startLine: block.start_line, - endLine: block.end_line, - segmentHash: block.segmentHash, - }, - } - }) - - // Upsert points to Qdrant - await this.qdrantClient.upsertPoints(points) - onBlocksIndexed?.(batchBlocks.length) - - // Update hashes for successfully processed files in this batch - for (const fileInfo of batchFileInfos) { - await this.cacheManager.updateHash(fileInfo.filePath, fileInfo.fileHash) - } - success = true - } catch (error) { - lastError = error as Error - console.error( - `[DirectoryScanner] Error processing batch (attempt ${attempts}) in workspace ${scanWorkspace}:`, - error, - ) - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), - stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, - location: "processBatch:retry", - attemptNumber: attempts, - batchSize: batchBlocks.length, - }) - - if (attempts < MAX_BATCH_RETRIES) { - const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempts - 1) - await new Promise((resolve) => setTimeout(resolve, delay)) - } - } - } - - if (!success && lastError) { - console.error(`[DirectoryScanner] Failed to process batch after ${MAX_BATCH_RETRIES} attempts`) - if (onError) { - // Preserve the original error message from embedders which now have detailed i18n messages - const errorMessage = lastError.message || "Unknown error" - - // For other errors, provide context - onError( - new Error( - t("embeddings:scanner.failedToProcessBatchWithError", { - maxRetries: MAX_BATCH_RETRIES, - errorMessage, - }), + ): void { + console.error(`Error processing file ${filePath} in workspace ${scanWorkspace}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location, + }) + onError?.( + error instanceof Error + ? new Error(`${error.message} (Workspace: ${scanWorkspace}, File: ${filePath})`) + : new Error( + t("embeddings:scanner.unknownErrorProcessingFile", { filePath }) + + ` (Workspace: ${scanWorkspace})`, ), - ) - } - } + ) } } diff --git a/src/services/code-index/provider-pacer.ts b/src/services/code-index/provider-pacer.ts new file mode 100644 index 0000000000..fe04441ba8 --- /dev/null +++ b/src/services/code-index/provider-pacer.ts @@ -0,0 +1,195 @@ +import type { Disposable, Event } from "vscode" +import { PACER_MAX_BACKOFF_MS } from "./constants" +import { classifyIndexingError, EmbeddingError } from "./shared/indexing-errors" + +const MAX_TIMEOUT_MS = 2 ** 31 - 1 + +export interface ProviderWait { + service: string + resumeAt?: number + waitingFiles: string[] +} + +type Waiter = { + path?: string + signal?: AbortSignal + resolve: () => void + reject: (error: unknown) => void + onAbort: () => void +} + +function serviceLabel(scope: string): string { + const prefix = scope.split(":", 1)[0] + return /^[a-z][a-z0-9-]{0,63}$/i.test(prefix) ? prefix : "provider" +} + +// In-memory equality ids: hashing the API key would be flagged as weak password hashing and adds nothing here. +const scopeIds = new Map() + +export function makeRateLimitScope(provider: string, baseUrl?: string, apiKey?: string, extra?: string): string { + let normalizedUrl = "" + if (baseUrl) { + const parsedUrl = new URL(baseUrl) + parsedUrl.username = "" + parsedUrl.password = "" + parsedUrl.hash = "" + normalizedUrl = parsedUrl.toString().replace(/\/+$/, "") + } + const key = JSON.stringify([provider, normalizedUrl, apiKey ?? "", extra ?? ""]) + let id = scopeIds.get(key) + if (id === undefined) { + id = scopeIds.size + 1 + scopeIds.set(key, id) + } + return `${serviceLabel(provider)}:${id}` +} + +/** Pause gate shared by every request on one service allowance. */ +export class ProviderPacer { + readonly service: string + private pausedUntil = 0 + private consecutiveRateLimits = 0 + private timer?: ReturnType + private readonly waiters = new Set() + private readonly changes = new Set<() => void>() + + constructor(scope: string) { + this.service = serviceLabel(scope) + } + + readonly onDidChange: Event = (listener, thisArgument, disposables) => { + const callback = () => listener.call(thisArgument, undefined) + this.changes.add(callback) + const subscription: Disposable = { dispose: () => void this.changes.delete(callback) } + disposables?.push(subscription) + return subscription + } + + get resumeAt(): number | undefined { + return this.pausedUntil > Date.now() ? this.pausedUntil : undefined + } + + get waitingPaths(): string[] { + return [...new Set([...this.waiters].flatMap((waiter) => (waiter.path === undefined ? [] : [waiter.path])))] + } + + get hasWaitingRequests(): boolean { + return this.waiters.size > 0 + } + + async run(path: string | undefined, signal: AbortSignal | undefined, request: () => Promise): Promise { + while (true) { + if (Date.now() < this.pausedUntil) await this.wait(path, signal) + signal?.throwIfAborted() + try { + const result = await request() + this.consecutiveRateLimits = 0 + return result + } catch (error) { + if (classifyIndexingError(error, signal) !== "rate-limit") throw error + this.pause(error) + } + } + } + + private pause(error: unknown): void { + const now = Date.now() + // Concurrent 429s from requests sent before the pause belong to the same episode. + if (now >= this.pausedUntil) this.consecutiveRateLimits++ + const requested = new EmbeddingError("Provider rate limited", error).retryAfterMs + const delay = + typeof requested === "number" && requested > 0 && Number.isFinite(new Date(now + requested).getTime()) + ? requested + : Math.min(PACER_MAX_BACKOFF_MS, 1000 * 2 ** Math.min(30, this.consecutiveRateLimits - 1)) + this.pausedUntil = Math.max(this.pausedUntil, now + delay) + this.schedule() + this.changed() + } + + private wait(path: string | undefined, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason) + return + } + const waiter: Waiter = { + path, + signal, + resolve, + reject, + onAbort: () => { + this.waiters.delete(waiter) + reject(signal?.reason) + this.changed() + }, + } + this.waiters.add(waiter) + signal?.addEventListener("abort", waiter.onAbort, { once: true }) + this.schedule() + this.changed() + }) + } + + private schedule(): void { + if (this.timer !== undefined) clearTimeout(this.timer) + const remaining = this.pausedUntil - Date.now() + if (remaining > 0) { + // setTimeout clamps delays above 2^31-1 ms to 1 ms, so long waits run in chunks. + this.timer = setTimeout(() => this.schedule(), Math.min(MAX_TIMEOUT_MS, Math.ceil(remaining))) + return + } + this.timer = undefined + for (const waiter of this.waiters) { + waiter.signal?.removeEventListener("abort", waiter.onAbort) + waiter.resolve() + } + this.waiters.clear() + this.changed() + } + + private changed(): void { + for (const listener of this.changes) listener() + } +} + +export class ProviderPacerRegistry { + private readonly pacers = new Map() + private readonly changes = new Set<() => void>() + + readonly onDidChange: Event = (listener, thisArgument, disposables) => { + const callback = () => listener.call(thisArgument, undefined) + this.changes.add(callback) + const subscription: Disposable = { dispose: () => void this.changes.delete(callback) } + disposables?.push(subscription) + return subscription + } + + get(scope: string): ProviderPacer { + let pacer = this.pacers.get(scope) + if (!pacer) { + pacer = new ProviderPacer(scope) + pacer.onDidChange(() => { + for (const listener of this.changes) listener() + }) + this.pacers.set(scope, pacer) + } + return pacer + } + + providerWaits(): ProviderWait[] { + return [...this.pacers.values()].flatMap((pacer) => { + const resumeAt = pacer.resumeAt + return resumeAt === undefined + ? [] + : [{ service: pacer.service, resumeAt, waitingFiles: pacer.waitingPaths }] + }) + } + + hasWaitingRequests(): boolean { + return [...this.pacers.values()].some((pacer) => pacer.hasWaitingRequests && pacer.resumeAt !== undefined) + } + + waitingPaths(): string[] { + return [...new Set([...this.pacers.values()].flatMap((pacer) => pacer.waitingPaths))] + } +} diff --git a/src/services/code-index/search-service.ts b/src/services/code-index/search-service.ts index a56f5cc674..308d29aa02 100644 --- a/src/services/code-index/search-service.ts +++ b/src/services/code-index/search-service.ts @@ -6,27 +6,43 @@ import { CodeIndexConfigManager } from "./config-manager" import { CodeIndexStateManager } from "./state-manager" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import type { FileFailureRegistry } from "./file-failure-registry" +import type { ProviderPacerRegistry } from "./provider-pacer" +import { retryWithPolicy } from "./shared/retry-policy" +import { classifyIndexingError, SearchProviderRateLimitError } from "./shared/indexing-errors" +import { t } from "../../i18n" + +export interface CodeIndexSearchResponse { + results: VectorStoreSearchResult[] + incomplete: boolean + failedPaths: string[] + waitingPaths: string[] +} /** * Service responsible for searching the code index. */ export class CodeIndexSearchService { constructor( - private readonly configManager: CodeIndexConfigManager, - private readonly stateManager: CodeIndexStateManager, - private readonly embedder: IEmbedder, - private readonly vectorStore: IVectorStore, + private readonly configManager: Pick< + CodeIndexConfigManager, + "isFeatureEnabled" | "isFeatureConfigured" | "currentSearchMinScore" | "currentSearchMaxResults" + >, + private readonly stateManager: Pick, + private readonly embedder: Pick, + private readonly vectorStore: Pick, + private readonly failures: Pick, + private readonly providerPacers: Pick, ) {} /** * Searches the code index for relevant content. * @param query The search query - * @param limit Maximum number of results to return * @param directoryPrefix Optional directory path to filter results by - * @returns Array of search results + * @returns Search results with incomplete-index details * @throws Error if the service is not properly configured or ready */ - public async searchIndex(query: string, directoryPrefix?: string): Promise { + public async searchIndex(query: string, directoryPrefix?: string): Promise { if (!this.configManager.isFeatureEnabled || !this.configManager.isFeatureConfigured) { throw new Error("Code index feature is disabled or not configured.") } @@ -34,15 +50,29 @@ export class CodeIndexSearchService { const minScore = this.configManager.currentSearchMinScore const maxResults = this.configManager.currentSearchMaxResults + const initialState = this.stateManager.getCurrentStatus().systemStatus + const partialStandby = + initialState === "Standby" && this.hasOutstandingWork() && (await this.vectorStore.collectionExists()) + // The collection lookup is awaited, so a stop or error may have started meanwhile. const currentState = this.stateManager.getCurrentStatus().systemStatus - if (currentState !== "Indexed" && currentState !== "Indexing") { - // Allow search during Indexing too + const ready = + currentState === "Indexed" || currentState === "Indexing" || (partialStandby && currentState === "Standby") + if (!ready) { throw new Error(`Code index is not ready for search. Current state: ${currentState}`) } try { - // Generate embedding for query - const embeddingResponse = await this.embedder.createEmbeddings([query]) + const embeddingResponse = await retryWithPolicy( + async () => { + try { + return await this.embedder.createEmbeddings([query]) + } catch (error) { + if (classifyIndexingError(error) !== "rate-limit") throw error + throw new SearchProviderRateLimitError(t("embeddings:searchProviderRateLimited"), error) + } + }, + { budget: { retriesUsed: 0 } }, + ) const vector = embeddingResponse?.embeddings[0] if (!vector) { throw new Error("Failed to generate embedding for query.") @@ -56,10 +86,18 @@ export class CodeIndexSearchService { // Perform search const results = await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults) - return results + const failedPaths = [...new Set(this.failures.list().map((failure) => failure.path))] + const waitingPaths = this.providerPacers.waitingPaths() + // Only Indexed proves the scan finished; a stop during the query leaves Standby without that proof. + const complete = this.stateManager.getCurrentStatus().systemStatus === "Indexed" + return { + results, + incomplete: !complete || failedPaths.length > 0 || waitingPaths.length > 0, + failedPaths, + waitingPaths, + } } catch (error) { console.error("[CodeIndexSearchService] Error during search:", error) - this.stateManager.setSystemState("Error", `Search failed: ${(error as Error).message}`) // Capture telemetry for the error TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { @@ -67,8 +105,11 @@ export class CodeIndexSearchService { stack: (error as Error).stack, location: "searchIndex", }) - - throw error // Re-throw the error after setting state + throw error } } + + private hasOutstandingWork(): boolean { + return this.failures.list().length > 0 || this.providerPacers.waitingPaths().length > 0 + } } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index d2bbe43f2a..00e5e8ce2e 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -14,6 +14,8 @@ import { FileWatcherFactory } from "./processors/file-watcher-factory" import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" +import type { FileFailureRegistry } from "./file-failure-registry" +import type { ProviderPacerRegistry } from "./provider-pacer" /** * Factory class responsible for creating and configuring code indexing service dependencies. @@ -32,6 +34,8 @@ export class CodeIndexServiceFactory { private readonly configManager: CodeIndexConfigManager, private readonly workspacePath: string, private readonly cacheManager: CacheManager, + private readonly failureRegistry: FileFailureRegistry, + private readonly pacers: ProviderPacerRegistry, ) {} /** @@ -66,8 +70,8 @@ export class CodeIndexServiceFactory { } const config = this.configManager.getConfig() - const embedder = this.embedderFactory.create(config) - const vectorStore = this.vectorStoreFactory.create(config, this.workspacePath) + const embedder = this.embedderFactory.create(config, this.pacers) + const vectorStore = this.vectorStoreFactory.create(config, this.workspacePath, this.pacers) const parser = codeParser const scanner = this.directoryScannerFactory.create({ embedder, @@ -75,6 +79,8 @@ export class CodeIndexServiceFactory { parser, cacheManager: this.cacheManager, ignoreInstance, + failureRegistry: this.failureRegistry, + pacers: this.pacers, }) const fileWatcher = this.fileWatcherFactory.create({ workspacePath: this.workspacePath, @@ -84,6 +90,8 @@ export class CodeIndexServiceFactory { cacheManager, ignoreInstance, rooIgnoreController, + failureRegistry: this.failureRegistry, + pacers: this.pacers, }) return { diff --git a/src/services/code-index/shared/__tests__/indexing-errors.spec.ts b/src/services/code-index/shared/__tests__/indexing-errors.spec.ts new file mode 100644 index 0000000000..7183c883d0 --- /dev/null +++ b/src/services/code-index/shared/__tests__/indexing-errors.spec.ts @@ -0,0 +1,200 @@ +import { APIConnectionError, APIConnectionTimeoutError, APIUserAbortError } from "openai" +import { QdrantClientTimeoutError } from "@qdrant/js-client-rest" +import { + classifyIndexingError, + getIndexingErrorStatusCode, + EmbeddingError, + VectorStoreError, + parseRetryAfterMs, + isSourceMissing, + readSource, + SOURCE_MISSING, + isQuotaError, +} from "../indexing-errors" +import { toEmbeddingError } from "../validation-helpers" +import { IndexingRetryError } from "../indexing-errors" + +describe("quota 429 classification", () => { + it.each([ + ["code", { status: 429, code: "insufficient_quota" }], + ["type", { status: 429, type: "insufficient_quota" }], + ["nested body", { status: 429, error: { code: "insufficient_quota" } }], + ])("CI-IE-01: insufficient_quota by %s is permanent", (_label, error) => { + expect(classifyIndexingError(error)).toBe("permanent") + expect(isQuotaError(error)).toBe(true) + }) + + it.each([ + ["quota wording", { status: 429, message: "You exceeded your current quota per minute" }], + ["RESOURCE_EXHAUSTED", { status: 429, code: "RESOURCE_EXHAUSTED" }], + ["unknown body", { status: 429, error: { code: "something_else" } }], + ])("CI-IE-02: %s stays rate-limit", (_label, error) => { + expect(classifyIndexingError(error)).toBe("rate-limit") + expect(isQuotaError(error)).toBe(false) + }) + + it("is not a quota error without a 429 status", () => { + expect(isQuotaError({ status: 400, code: "insufficient_quota" })).toBe(false) + }) +}) + +vi.mock("../../../../i18n", () => ({ t: (key: string) => key })) + +describe("T44 isSourceMissing and readSource", () => { + const withCode = (code?: string) => Object.assign(new Error("x"), code ? { code } : {}) + + it.each([ + ["ENOENT", true], + ["FileNotFound", true], + ["EACCES", false], + ["EPERM", false], + ["NoPermissions", false], + ["Unknown", false], + ["EISDIR", false], + ["ENOTDIR", false], + [undefined, false], + ])("code %s -> %s", (code, expected) => { + expect(isSourceMissing(withCode(code))).toBe(expected) + }) + + it("ignores message text and non-error values", () => { + expect(isSourceMissing(new Error("ENOENT: no such file"))).toBe(false) + expect(isSourceMissing(undefined)).toBe(false) + expect(isSourceMissing("ENOENT")).toBe(false) + }) + + it("maps only a missing source to the sentinel and rethrows anything else unchanged", async () => { + expect(await readSource(Promise.resolve(5))).toBe(5) + expect(await readSource(Promise.reject(withCode("FileNotFound")))).toBe(SOURCE_MISSING) + const denied = withCode("EACCES") + await expect(readSource(Promise.reject(denied))).rejects.toBe(denied) + }) +}) + +describe("indexing error classification", () => { + it.each([ + [{ status: 429 }, "rate-limit"], + [{ status: 500 }, "server"], + [{ statusCode: 503 }, "server"], + [{ response: { status: 502 } }, "server"], + [{ $metadata: { httpStatusCode: 503 } }, "server"], + [{ status: 408 }, "permanent"], + [{ status: 409 }, "permanent"], + [{ status: 401 }, "permanent"], + [{ status: "500", statusCode: 429 }, "rate-limit"], + [{ status: 400, statusCode: 503 }, "permanent"], + [{ status: 400, code: "ECONNRESET" }, "permanent"], + [new Error("HTTP 503: ECONNREFUSED"), "permanent"], + [new TypeError("validation"), "permanent"], + [new TypeError("fetch failed", { cause: new Error("network") }), "connection"], + [new APIConnectionError({ cause: new Error("network") }), "connection"], + [new APIConnectionTimeoutError(), "connection"], + [new QdrantClientTimeoutError("timeout"), "connection"], + [{ name: "TimeoutError" }, "connection"], + [{ name: "AbortError", status: 503 }, "permanent"], + [new APIUserAbortError(), "permanent"], + [{ name: "ThrottlingException" }, "permanent"], + [undefined, "permanent"], + ] as const)("classifies %o as %s", (error, classification) => { + expect(classifyIndexingError(error)).toBe(classification) + }) + + it.each([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "EAI_AGAIN", + "EPIPE", + "UND_ERR_SOCKET", + "ERR_HTTP2_STREAM_CANCEL", + ])("walks causes for %s", (code) => { + expect(classifyIndexingError(new Error("outer", { cause: { code } }))).toBe("connection") + }) + + it("handles cycles and preserves status precedence", () => { + const error = { status: 400, cause: {} } + error.cause = error + expect(classifyIndexingError(error)).toBe("permanent") + expect(getIndexingErrorStatusCode({ cause: { statusCode: 503 } })).toBe(503) + }) + + it("lets caller cancellation override structured transient errors", () => { + const error = new EmbeddingError("server", { status: 503 }) + expect(classifyIndexingError(error, AbortSignal.abort())).toBe("permanent") + }) + + it.each([EmbeddingError, VectorStoreError])("preserves fields and the raw cause in %s", (ErrorClass) => { + const cause = { $metadata: { httpStatusCode: 503 } } + const error = new ErrorClass("unchanged", cause) + expect(error.message).toBe("unchanged") + expect(error.cause).toBe(cause) + expect(error.statusCode).toBe(503) + expect(error.classification).toBe("server") + expect(classifyIndexingError(new Error("outer", { cause: error }))).toBe("server") + }) + + it("classifies raw errors before translated formatting", () => { + const cause = new APIConnectionTimeoutError() + const error = toEmbeddingError(cause) + expect(error).toBeInstanceOf(EmbeddingError) + expect(error.message).toBe("embeddings:failedWithError") + expect(error.cause).toBe(cause) + expect(error.classification).toBe("connection") + }) +}) + +describe("parseRetryAfterMs", () => { + const now = Date.parse("2026-11-10T12:00:00Z") + it.each([ + [{ "Retry-After": "2" }, 2000], + [new Headers({ "retry-after-ms": "2500", "retry-after": "60" }), 2500], + [{ "retry-after-ms": "invalid", "retry-after": "60" }, 60_000], + [{ "retry-after-ms": "-1", "retry-after": "61" }, 61_000], + [{ "retry-after": new Date(now + 61_000).toUTCString() }, 61_000], + [{ "retry-after": new Date(now - 1000).toUTCString() }, undefined], + [{ "retry-after": "-1" }, undefined], + [{ "retry-after": "" }, undefined], + [{ "retry-after": "invalid" }, undefined], + [{ "retry-after": "Infinity" }, undefined], + [7, 7000], + [0, 0], + [-1, undefined], + [Infinity, undefined], + [{ "retry-after-ms": "1e308" }, undefined], + [1e300, undefined], + [undefined, undefined], + ] as const)("parses %o", (source, expected) => { + expect(parseRetryAfterMs(source, now)).toBe(expected) + }) + + it.each([EmbeddingError, VectorStoreError])("preserves delay and explicit override in %s", (ErrorClass) => { + const cause = { status: 429, headers: new Headers({ "retry-after": "2" }) } + expect(new ErrorClass("limited", cause)).toMatchObject({ retryAfterMs: 2000, cause }) + expect(new ErrorClass("limited", cause, { retryAfterMs: 7000 })).toMatchObject({ retryAfterMs: 7000 }) + }) +}) + +it.each(["2", "invalid", undefined])( + "discovers header delay once across formatting and retry wrappers: %s", + (value) => { + const get = vi.fn((name: string) => (name === "retry-after" ? value : undefined)) + const original = { status: 429, headers: { get } } + const formatted = toEmbeddingError(original) + const retried = new IndexingRetryError(formatted, 3, "rate-limit", "embed") + expect(retried.retryAfterMs).toBe(value === "2" ? 2000 : undefined) + expect(formatted.retryAfterMs).toBe(retried.retryAfterMs) + expect(get.mock.calls.filter(([name]) => name === "retry-after")).toHaveLength(1) + get.mockClear() + const constructed = new EmbeddingError("limited", original) + expect(constructed.retryAfterMs).toBe(formatted.retryAfterMs) + expect(get.mock.calls.filter(([name]) => name === "retry-after")).toHaveLength(1) + }, +) + +it("explicit delay wins without reading headers", () => { + const get = vi.fn() + const error = new VectorStoreError("limited", { status: 429, headers: { get } }, { retryAfterMs: 7000 }) + expect(error.retryAfterMs).toBe(7000) + expect(get).not.toHaveBeenCalled() +}) diff --git a/src/services/code-index/shared/__tests__/provider-wait-message.spec.ts b/src/services/code-index/shared/__tests__/provider-wait-message.spec.ts new file mode 100644 index 0000000000..26b7d2a9bc --- /dev/null +++ b/src/services/code-index/shared/__tests__/provider-wait-message.spec.ts @@ -0,0 +1,31 @@ +import { formatProviderWaitMessage } from "../provider-wait-message" +import { t } from "../../../../i18n" +import { clearAllMocks } from "../../../../test-utils/reset" + +vi.mock("../../../../i18n", () => ({ t: vi.fn((key: string) => key) })) + +describe("formatProviderWaitMessage", () => { + beforeEach(() => clearAllMocks()) + + it.each([undefined, 123_000])("formats a provider wait with resume time %s", (resumeAt) => { + const key = + resumeAt === undefined + ? "embeddings:orchestrator.providerWaiting" + : "embeddings:orchestrator.providerWaitingUntil" + expect(formatProviderWaitMessage([{ service: "embedding", resumeAt, waitingFiles: ["waiting.ts"] }])).toBe(key) + expect(t).toHaveBeenCalledExactlyOnceWith(key, { + service: "embedding", + time: resumeAt === undefined ? undefined : new Date(resumeAt).toISOString(), + }) + }) + + it("joins separate provider messages and accepts no waits", () => { + expect(formatProviderWaitMessage([])).toBe("") + expect( + formatProviderWaitMessage([ + { service: "embedding", waitingFiles: [] }, + { service: "qdrant", resumeAt: 123_000, waitingFiles: ["waiting.ts"] }, + ]), + ).toBe("embeddings:orchestrator.providerWaiting\nembeddings:orchestrator.providerWaitingUntil") + }) +}) diff --git a/src/services/code-index/shared/__tests__/retry-policy.spec.ts b/src/services/code-index/shared/__tests__/retry-policy.spec.ts new file mode 100644 index 0000000000..a206d5d898 --- /dev/null +++ b/src/services/code-index/shared/__tests__/retry-policy.spec.ts @@ -0,0 +1,210 @@ +import { ProviderPacer } from "../../provider-pacer" +import { retryWithPolicy, type RetryFailure } from "../retry-policy" +import { EmbeddingError, IndexingRetryError } from "../indexing-errors" +import { APIUserAbortError } from "openai" + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +it("paces repeated limits even after both retries were consumed, then preserves exhaustion", async () => { + const budget = { retriesUsed: 0 } + const pacer = new ProviderPacer("openai:test") + const onFailure = vi.fn() + const unavailable = new EmbeddingError("server", { status: 503 }) + const limited = new EmbeddingError("limited", { status: 429 }, { retryAfterMs: 120_000 }) + const request = vi + .fn() + .mockRejectedValueOnce(unavailable) + .mockRejectedValueOnce(unavailable) + .mockResolvedValueOnce("first batch") + const run = () => retryWithPolicy(() => pacer.run("test.ts", undefined, request), { budget, onFailure }) + const first = run() + await vi.advanceTimersByTimeAsync(1500) + await expect(first).resolves.toBe("first batch") + expect(budget.retriesUsed).toBe(2) + expect(onFailure).toHaveBeenCalledTimes(2) + request + .mockRejectedValueOnce(limited) + .mockRejectedValueOnce(limited) + .mockRejectedValueOnce(limited) + .mockRejectedValueOnce(limited) + .mockResolvedValueOnce("second batch") + const second = run() + await vi.advanceTimersByTimeAsync(0) + for (const index of [0, 1, 2, 3]) { + expect(request).toHaveBeenCalledTimes(4 + index) + expect(budget.retriesUsed).toBe(2) + expect(onFailure).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(120_000) + } + await expect(second).resolves.toBe("second batch") + request.mockRejectedValueOnce(unavailable) + const exhausted = run().catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(375) + expect(await exhausted).toMatchObject({ classification: "server", attempts: 3 }) + expect(request).toHaveBeenCalledTimes(9) + expect(onFailure).toHaveBeenCalledTimes(3) +}) + +it.each([undefined, 100, 2000, 60_000, 120_000, NaN, -1])( + "CI-RP-03: rethrows rate limits unchanged before callbacks or budget use: %s", + async (retryAfterMs) => { + const original = Object.freeze(new EmbeddingError("limited", { status: 429 }, { retryAfterMs })) + const request = vi.fn().mockRejectedValue(original) + const onFailure = vi.fn() + const budget = { retriesUsed: 2 } + await expect(retryWithPolicy(request, { budget, onFailure })).rejects.toBe(original) + expect(budget.retriesUsed).toBe(2) + expect(onFailure).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }, +) + +it.each([503, { code: "ECONNRESET" }])( + "CI-RP-01/02: at most 3 requests, waits 500 ms then 1 s: %s", + async (failure) => { + const cause = typeof failure === "number" ? { status: failure } : failure + const request = vi.fn().mockRejectedValue(new EmbeddingError("failed", cause)) + const failures: RetryFailure[] = [] + const pending = retryWithPolicy(request, { onFailure: (failure) => failures.push(failure) }).catch( + (error) => error, + ) + for (const [index, delay] of [500, 1000].entries()) { + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(index + 1) + expect(failures[index]).toMatchObject({ delayMs: delay, delaySource: "default", willRetry: true }) + await vi.advanceTimersByTimeAsync(delay - 1) + expect(request).toHaveBeenCalledTimes(index + 1) + await vi.advanceTimersByTimeAsync(1) + } + expect(await pending).toMatchObject({ attempts: 3 }) + expect(request).toHaveBeenCalledTimes(3) + }, +) + +it("CI-RP-02: abort ends a wait at once without another request", async () => { + const controller = new AbortController() + const request = vi.fn().mockRejectedValue(new EmbeddingError("unavailable", { status: 503 })) + const pending = retryWithPolicy(request, { signal: controller.signal }).catch((error) => error) + await vi.advanceTimersByTimeAsync(0) + controller.abort() + expect(await pending).toBe(controller.signal.reason) + await vi.advanceTimersByTimeAsync(120_000) + expect(request).toHaveBeenCalledOnce() +}) + +it.each([401, 408, 409])("CI-RP-04: does not retry permanent HTTP %s", async (status) => { + const request = vi.fn().mockRejectedValue(new EmbeddingError("failed", { status })) + await expect(retryWithPolicy(request)).rejects.toMatchObject({ + attempts: 1, + }) + expect(request).toHaveBeenCalledOnce() +}) + +it("returns a successful value and never issues an aborted first request", async () => { + const request = vi.fn().mockResolvedValue("result") + await expect(retryWithPolicy(request)).resolves.toBe("result") + await expect(retryWithPolicy(request, { signal: AbortSignal.abort() })).rejects.toMatchObject({ + name: "AbortError", + }) + expect(request).toHaveBeenCalledOnce() +}) + +it("shares two retries across successful batches without charging their first calls", async () => { + const budget = { retriesUsed: 0 } + const request = vi.fn().mockResolvedValue("result") + await retryWithPolicy(request, { budget }) + await retryWithPolicy(request, { budget }) + expect(budget.retriesUsed).toBe(0) + request.mockRejectedValueOnce({ status: 503 }).mockRejectedValueOnce({ status: 503 }) + const recovered = retryWithPolicy(request, { budget }) + await vi.advanceTimersByTimeAsync(1500) + await recovered + expect(budget.retriesUsed).toBe(2) + request.mockRejectedValue({ status: 503 }) + await expect(retryWithPolicy(request, { budget })).rejects.toMatchObject({ attempts: 3 }) + expect(request).toHaveBeenCalledTimes(6) +}) + +it("wraps a frozen error without changing its metadata", async () => { + const original = Object.freeze(new EmbeddingError("unavailable", { status: 503 }, { retryAfterMs: 2000 })) + const properties = Object.getOwnPropertyDescriptors(original) + const request = vi.fn().mockRejectedValue(original) + const pending = retryWithPolicy(request, { activity: "embed" }).catch((error) => error) + await vi.advanceTimersByTimeAsync(4000) + const error = await pending + expect(error).toBeInstanceOf(IndexingRetryError) + expect(error).toMatchObject({ + message: original.message, + attempts: 3, + classification: "server", + statusCode: 503, + retryAfterMs: 2000, + activity: "embed", + }) + expect((error as IndexingRetryError).cause).toBe(original) + expect(Object.getOwnPropertyDescriptors(original)).toEqual(properties) + expect(request).toHaveBeenCalledTimes(3) +}) + +it("keeps independent metadata when one error is reused across operations", async () => { + const original = Object.assign(new Error("unavailable"), { status: 503 }) + const properties = Object.getOwnPropertyDescriptors(original) + const request = vi.fn().mockRejectedValue(original) + const firstPending = retryWithPolicy(request, { activity: "upsert" }).catch((error) => error) + await vi.advanceTimersByTimeAsync(1500) + const first = await firstPending + const second = await retryWithPolicy(request, { activity: "delete", budget: { retriesUsed: 2 } }).catch( + (error) => error, + ) + expect(first).toBeInstanceOf(IndexingRetryError) + expect(second).toBeInstanceOf(IndexingRetryError) + expect(first).not.toBe(second) + expect(first).toMatchObject({ attempts: 3, activity: "upsert", classification: "server", statusCode: 503 }) + expect(second).toMatchObject({ attempts: 3, activity: "delete", classification: "server", statusCode: 503 }) + expect((first as IndexingRetryError).cause).toBe(original) + expect((second as IndexingRetryError).cause).toBe(original) + expect(Object.getOwnPropertyDescriptors(original)).toEqual(properties) + expect(original).not.toHaveProperty("attempts") + expect(original).not.toHaveProperty("activity") +}) + +it.each([ + new DOMException("cancelled", "AbortError"), + new APIUserAbortError(), + Object.freeze(Object.assign(new Error("cancelled"), { name: "APIUserAbortError" })), +])("preserves live-signal cancellation identity: %s", async (original) => { + const properties = Object.getOwnPropertyDescriptors(original) + const request = vi.fn().mockRejectedValue(original) + const onFailure = vi.fn() + await expect( + retryWithPolicy(request, { signal: new AbortController().signal, activity: "embed", onFailure }), + ).rejects.toBe(original) + expect(Object.getOwnPropertyDescriptors(original)).toEqual(properties) + expect(original).not.toHaveProperty("attempts") + expect(original).not.toHaveProperty("activity") + expect(onFailure).toHaveBeenCalledWith(expect.objectContaining({ classification: "permanent", willRetry: false })) + expect(request).toHaveBeenCalledOnce() +}) + +it("rethrows signal.reason rather than an operation rejection after abort", async () => { + const controller = new AbortController() + const reason = Object.freeze(new DOMException("stopped", "AbortError")) + const rejection = new Error("operation rejected") + const request = vi.fn(async () => { + controller.abort(reason) + throw rejection + }) + const onFailure = vi.fn() + await expect(retryWithPolicy(request, { signal: controller.signal, activity: "embed", onFailure })).rejects.toBe( + controller.signal.reason, + ) + expect(reason.name).toBe("AbortError") + expect(reason).not.toHaveProperty("attempts") + expect(reason).not.toHaveProperty("activity") + expect(rejection).not.toHaveProperty("attempts") + expect(rejection).not.toHaveProperty("activity") + expect(onFailure).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledOnce() +}) diff --git a/src/services/code-index/shared/indexing-errors.ts b/src/services/code-index/shared/indexing-errors.ts new file mode 100644 index 0000000000..f021ba8a9d --- /dev/null +++ b/src/services/code-index/shared/indexing-errors.ts @@ -0,0 +1,207 @@ +import { APIConnectionError, APIUserAbortError } from "openai" +import type { FileFailureActivity } from "../file-failure-registry" + +export type TransientClass = "rate-limit" | "server" | "connection" +export type IndexingErrorClassification = TransientClass | "permanent" + +type ErrorDetails = { + classification?: IndexingErrorClassification + statusCode?: number + retryAfterMs?: number +} + +type ErrorShape = ErrorDetails & { + name?: string + code?: string + type?: string + error?: { code?: string; type?: string } + status?: number + headers?: RetryAfterHeaders + response?: { status?: number } + $metadata?: { httpStatusCode?: number } + cause?: unknown +} + +type RetryAfterHeaders = Headers | Record + +// ENOENT: Node fs/promises; FileNotFound: vscode.FileSystemError. Other codes stay read failures. +export function isSourceMissing(error: unknown): boolean { + const code = (error as ErrorShape | undefined)?.code + return code === "ENOENT" || code === "FileNotFound" +} + +export const SOURCE_MISSING = Symbol("source-missing") + +/** Applies missing-source detection only to the file's own stat or read call. */ +export async function readSource(read: PromiseLike): Promise { + try { + return await read + } catch (error) { + if (isSourceMissing(error)) return SOURCE_MISSING + throw error + } +} + +function nonnegativeNumber(value: unknown): number | undefined { + if (typeof value !== "number" && typeof value !== "string") return undefined + if (typeof value === "string" && !value.trim()) return undefined + const number = Number(value) + return Number.isFinite(number) && number >= 0 ? number : undefined +} + +// A delay whose resume deadline falls outside the Date range would make toISOString() throw. +function validDelay(delay: number | undefined, now: number): number | undefined { + return delay !== undefined && Number.isFinite(new Date(now + delay).getTime()) ? delay : undefined +} + +export function parseRetryAfterMs(source: unknown, now = Date.now()): number | undefined { + return validDelay(parseRawRetryAfterMs(source, now), now) +} + +function parseRawRetryAfterMs(source: unknown, now: number): number | undefined { + if (typeof source === "number") { + const seconds = nonnegativeNumber(source) + return seconds !== undefined && Number.isFinite(seconds * 1000) ? seconds * 1000 : undefined + } + if (!source || typeof source !== "object") return undefined + const headers = source as RetryAfterHeaders + let milliseconds: unknown + let retryAfter: unknown + if (typeof headers.get === "function") { + milliseconds = headers.get("retry-after-ms") + retryAfter = headers.get("retry-after") + } else { + for (const [name, value] of Object.entries(source)) { + if (name.toLowerCase() === "retry-after-ms") milliseconds = value + if (name.toLowerCase() === "retry-after") retryAfter = value + } + } + const parsedMilliseconds = nonnegativeNumber(milliseconds) + if (parsedMilliseconds !== undefined) return parsedMilliseconds + const seconds = nonnegativeNumber(retryAfter) + if (seconds !== undefined && Number.isFinite(seconds * 1000)) return seconds * 1000 + if (typeof retryAfter !== "string" || !/[a-z]/i.test(retryAfter)) return undefined + const delay = Date.parse(retryAfter) - now + return Number.isFinite(delay) && delay > 0 ? delay : undefined +} + +function errorChain(error: unknown): ErrorShape[] { + const chain: ErrorShape[] = [] + const visited = new Set() + while (error && typeof error === "object" && !visited.has(error)) { + visited.add(error) + chain.push(error as ErrorShape) + error = (error as ErrorShape).cause + } + return chain +} + +function numericStatus(error: ErrorShape): number | undefined { + return [error.status, error.statusCode, error.response?.status, error.$metadata?.httpStatusCode].find( + (status) => typeof status === "number" && Number.isFinite(status), + ) +} + +export function getIndexingErrorStatusCode(error: unknown): number | undefined { + for (const entry of errorChain(error)) { + const status = numericStatus(entry) + if (status !== undefined) return status + } + return undefined +} + +export function classifyIndexingError(error: unknown, signal?: AbortSignal): IndexingErrorClassification { + const chain = errorChain(error) + if ( + signal?.aborted || + error instanceof APIUserAbortError || + chain[0]?.name === "AbortError" || + chain[0]?.name === "APIUserAbortError" + ) { + return "permanent" + } + for (const entry of chain) { + if ( + entry.classification === "rate-limit" || + entry.classification === "server" || + entry.classification === "connection" || + entry.classification === "permanent" + ) { + return entry.classification + } + } + if (chain.some((entry) => entry instanceof APIUserAbortError || entry.name === "AbortError")) return "permanent" + const status = getIndexingErrorStatusCode(error) + if (status !== undefined) { + if (status === 429) return isQuotaExhausted(chain) ? "permanent" : "rate-limit" + return status >= 500 && status < 600 ? "server" : "permanent" + } + const networkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]) + for (const entry of chain) { + if ( + (typeof entry.code === "string" && + (networkCodes.has(entry.code) || + entry.code.startsWith("UND_ERR_") || + entry.code.startsWith("ERR_HTTP2_"))) || + (entry instanceof TypeError && entry.cause !== undefined) || + entry instanceof APIConnectionError || + entry.name === "APIConnectionError" || + entry.name === "APIConnectionTimeoutError" || + entry.name === "TimeoutError" || + entry.name === "QdrantClientTimeoutError" + ) { + return "connection" + } + } + return "permanent" +} + +// Only the structured OpenAI-style marker counts: per-minute throttle bodies can also mention "quota". +function isQuotaExhausted(chain: ErrorShape[]): boolean { + return chain.some((entry) => + [entry.code, entry.type, entry.error?.code, entry.error?.type].includes("insufficient_quota"), + ) +} + +export function isQuotaError(error: unknown): boolean { + return getIndexingErrorStatusCode(error) === 429 && isQuotaExhausted(errorChain(error)) +} + +class IndexingError extends Error { + readonly classification: IndexingErrorClassification + readonly statusCode?: number + + readonly retryAfterMs?: number + + constructor(message: string, cause: unknown, details?: ErrorDetails) { + super(message, { cause }) + this.name = new.target.name + this.classification = details?.classification ?? classifyIndexingError(cause) + this.statusCode = details?.statusCode ?? getIndexingErrorStatusCode(cause) + this.retryAfterMs = details?.retryAfterMs + if (this.retryAfterMs === undefined) { + for (const entry of errorChain(cause)) { + this.retryAfterMs = nonnegativeNumber(entry.retryAfterMs) + // Indexing-owned causes already checked for valid, missing or invalid headers. + if (entry instanceof IndexingError) break + this.retryAfterMs ??= parseRetryAfterMs(entry.headers) + if (this.retryAfterMs !== undefined) break + } + } + } +} + +export class EmbeddingError extends IndexingError {} +export class VectorStoreError extends IndexingError {} +export class SearchProviderRateLimitError extends IndexingError {} + +export class IndexingRetryError extends IndexingError { + constructor( + cause: unknown, + readonly attempts: number, + classification: IndexingErrorClassification, + readonly activity?: FileFailureActivity, + ) { + super(cause instanceof Error ? cause.message : String(cause), cause, { classification }) + } +} diff --git a/src/services/code-index/shared/provider-wait-message.ts b/src/services/code-index/shared/provider-wait-message.ts new file mode 100644 index 0000000000..0fc0e4d590 --- /dev/null +++ b/src/services/code-index/shared/provider-wait-message.ts @@ -0,0 +1,18 @@ +import type { ProviderWaitStatus } from "@roo-code/types" +import { t } from "../../../i18n" + +export function formatProviderWaitMessage(waits: readonly ProviderWaitStatus[]): string { + return waits + .map((wait) => + t( + wait.resumeAt === undefined + ? "embeddings:orchestrator.providerWaiting" + : "embeddings:orchestrator.providerWaitingUntil", + { + service: wait.service, + time: wait.resumeAt === undefined ? undefined : new Date(wait.resumeAt).toISOString(), + }, + ), + ) + .join("\n") +} diff --git a/src/services/code-index/shared/retry-policy.ts b/src/services/code-index/shared/retry-policy.ts new file mode 100644 index 0000000000..bf9b048dac --- /dev/null +++ b/src/services/code-index/shared/retry-policy.ts @@ -0,0 +1,65 @@ +import { APIUserAbortError } from "openai" +import { FILE_RETRY_ATTEMPTS, FILE_RETRY_BASE_DELAY_MS } from "../constants" +import { waitForRetry } from "../embedders/cancellation" +import { classifyIndexingError, IndexingRetryError, type IndexingErrorClassification } from "./indexing-errors" +import type { FileFailureActivity } from "../file-failure-registry" + +export interface RetryBudget { + retriesUsed: number +} + +export interface RetryFailure { + error: unknown + attempt: number + classification: IndexingErrorClassification + willRetry: boolean + delayMs: number + delaySource: "default" +} + +export interface RetryPolicyOptions { + signal?: AbortSignal + activity?: FileFailureActivity + budget?: RetryBudget + onFailure?: (failure: RetryFailure) => void +} + +export async function retryWithPolicy( + operation: () => Promise, + { signal, activity, budget = { retriesUsed: 0 }, onFailure }: RetryPolicyOptions = {}, +): Promise { + while (true) { + signal?.throwIfAborted() + try { + return await operation() + } catch (error) { + signal?.throwIfAborted() + const classification = classifyIndexingError(error, signal) + if (classification === "rate-limit") throw error + const attempt = budget.retriesUsed + 1 + const willRetry = classification !== "permanent" && attempt < FILE_RETRY_ATTEMPTS + const delayMs = willRetry ? FILE_RETRY_BASE_DELAY_MS * 2 ** budget.retriesUsed : 0 + if (willRetry) budget.retriesUsed++ + onFailure?.({ + error, + attempt, + classification, + willRetry, + delayMs, + delaySource: "default", + }) + if (!willRetry) { + if ( + error instanceof APIUserAbortError || + (error && + typeof error === "object" && + "name" in error && + (error.name === "AbortError" || error.name === "APIUserAbortError")) + ) + throw error + throw new IndexingRetryError(error, attempt, classification, activity) + } + await waitForRetry(delayMs, signal) + } + } +} diff --git a/src/services/code-index/shared/validation-helpers.ts b/src/services/code-index/shared/validation-helpers.ts index 025d004aa1..57a82c0238 100644 --- a/src/services/code-index/shared/validation-helpers.ts +++ b/src/services/code-index/shared/validation-helpers.ts @@ -1,5 +1,6 @@ import { t } from "../../../i18n" import { serializeError } from "serialize-error" +import { EmbeddingError, isQuotaError } from "./indexing-errors" /** * Sanitizes error messages by removing sensitive information like file paths and URLs @@ -205,6 +206,17 @@ export async function withValidationErrorHandling ({ t: (key: string) => key })) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function respondToUpsert(retryAfter: string | undefined) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input) + const limited = /\/points\?/.test(url) + return new Response( + JSON.stringify( + limited + ? { status: { error: "limited" }, time: 0 } + : { result: { status: "completed" }, status: "ok" }, + ), + { + status: limited ? 429 : 200, + headers: { + "content-type": "application/json", + ...(retryAfter === undefined ? {} : { "retry-after": retryAfter }), + }, + }, + ) + }), + ) +} + +const point = { id: "1", vector: [1, 0], payload: { filePath: "a.ts" } } + +describe("Qdrant Retry-After (CI-QD-02)", () => { + it.each([ + ["61", 61_000], + ["120", 120_000], + ])("reads the whole %s-second header, not its first character", async (header, expected) => { + respondToUpsert(header) + const store = new QdrantVectorStore( + path.resolve("/workspace"), + "http://example.test:6333", + 2, + undefined, + pacers, + ) + const error = await store.upsertPoints([point]).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(VectorStoreError) + expect(error).toMatchObject({ classification: "rate-limit", statusCode: 429, retryAfterMs: expected }) + expect((error as VectorStoreError).cause).toBeInstanceOf(QdrantClientResourceExhaustedError) + }) + + it("classifies a 429 without a header as a rate limit with no delay", async () => { + respondToUpsert(undefined) + const store = new QdrantVectorStore( + path.resolve("/workspace"), + "http://example.test:6333", + 2, + undefined, + pacers, + ) + const error = await store.upsertPoints([point]).catch((caught: unknown) => caught) + expect(error).toMatchObject({ classification: "rate-limit" }) + expect((error as VectorStoreError).retryAfterMs).toBeUndefined() + }) + + it("scopes Qdrant by normalized URL, independently of workspace and key", () => { + const first = new QdrantVectorStore("/first", "http://EXAMPLE.test:6333/", 2, "first-key", pacers) + const same = new QdrantVectorStore("/second", "example.test:6333", 2, "second-key", pacers) + const other = new QdrantVectorStore("/first", "http://example.test:6334", 2, undefined, pacers) + expect(first.rateLimitScope).toBe(same.rateLimitScope) + expect(first.rateLimitScope).not.toBe(other.rateLimitScope) + }) +}) diff --git a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts b/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts index feb3cebafb..eb3b58aaf5 100644 --- a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts +++ b/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts @@ -1,7 +1,8 @@ -import { QdrantClient } from "@qdrant/js-client-rest" +import { QdrantClient, QdrantClientResourceExhaustedError } from "@qdrant/js-client-rest" import { createHash } from "crypto" import { QdrantVectorStore } from "../qdrant-client" +import { ProviderPacerRegistry } from "../../provider-pacer" import { getWorkspacePath } from "../../../../utils/path" import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../constants" @@ -42,6 +43,9 @@ const mockQdrantClientInstance = { delete: vitest.fn(), } +// Recreated per test: a 429 pause left in a shared registry would delay every later request. +let pacers: ProviderPacerRegistry + const mockCreateHashInstance = { update: vitest.fn().mockReturnThis(), digest: vitest.fn(), @@ -58,6 +62,7 @@ describe("QdrantVectorStore", () => { beforeEach(() => { clearAllMocks() + pacers = new ProviderPacerRegistry() // Mock QdrantClient constructor ;(QdrantClient as any).mockImplementation(function () { @@ -72,7 +77,7 @@ describe("QdrantVectorStore", () => { // Mock getWorkspacePath ;(getWorkspacePath as any).mockReturnValue(mockWorkspacePath) - vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey) + vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey, pacers) }) it("should correctly initialize QdrantClient and collectionName in constructor", () => { @@ -94,7 +99,7 @@ describe("QdrantVectorStore", () => { expect((vectorStore as any).vectorSize).toBe(mockVectorSize) }) it("should handle constructor with default URL when none provided", () => { - new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) + new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize, undefined, pacers) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -108,7 +113,7 @@ describe("QdrantVectorStore", () => { }) it("should handle constructor without API key", () => { - new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize) + new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, undefined, pacers) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "mock-qdrant", @@ -128,6 +133,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "https://qdrant.ashbyfam.com", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "qdrant.ashbyfam.com", @@ -143,7 +150,13 @@ describe("QdrantVectorStore", () => { }) it("should use explicit port for HTTPS URLs with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://example.com:9000", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "https://example.com:9000", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "example.com", https: true, @@ -162,6 +175,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "https://example.com/api/v1?key=value", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "example.com", @@ -179,7 +194,13 @@ describe("QdrantVectorStore", () => { describe("HTTP URL handling", () => { it("should use explicit port 80 for HTTP URLs without port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://example.com", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "http://example.com", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "example.com", https: false, @@ -194,7 +215,13 @@ describe("QdrantVectorStore", () => { }) it("should use explicit port for HTTP URLs with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:8080", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:8080", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -213,6 +240,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://example.com/api/v1?key=value", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "example.com", @@ -230,7 +259,13 @@ describe("QdrantVectorStore", () => { describe("Hostname handling", () => { it("should convert hostname to http with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "qdrant.example.com", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "qdrant.example.com", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "qdrant.example.com", https: false, @@ -244,7 +279,13 @@ describe("QdrantVectorStore", () => { }) it("should handle hostname:port format with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "localhost:6333", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "localhost:6333", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -258,7 +299,13 @@ describe("QdrantVectorStore", () => { }) it("should handle explicit HTTP URLs correctly", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:9000", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:9000", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -274,7 +321,13 @@ describe("QdrantVectorStore", () => { describe("IP address handling", () => { it("should convert IP address to http with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "192.168.1.100", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "192.168.1.100", https: false, @@ -288,7 +341,13 @@ describe("QdrantVectorStore", () => { }) it("should handle IP:port format with explicit port", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100:6333", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "192.168.1.100:6333", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "192.168.1.100", https: false, @@ -304,7 +363,13 @@ describe("QdrantVectorStore", () => { describe("Edge cases", () => { it("should handle undefined URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + undefined as any, + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -318,7 +383,7 @@ describe("QdrantVectorStore", () => { }) it("should handle empty string URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize) + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize, undefined, pacers) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -332,7 +397,7 @@ describe("QdrantVectorStore", () => { }) it("should handle whitespace-only URL with host-based config", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize) + const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize, undefined, pacers) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", https: false, @@ -348,7 +413,13 @@ describe("QdrantVectorStore", () => { describe("Invalid URL fallback", () => { it("should treat invalid URLs as hostnames with port 80", () => { - const vectorStore = new QdrantVectorStore(mockWorkspacePath, "invalid-url-format", mockVectorSize) + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "invalid-url-format", + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "invalid-url-format", https: false, @@ -369,6 +440,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/some/path", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -388,6 +461,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -407,6 +482,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "https://qdrant.ashbyfam.com/api", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "qdrant.ashbyfam.com", @@ -426,6 +503,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/api/", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -445,6 +524,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/api///", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -464,6 +545,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/api/v1/qdrant", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -480,7 +563,13 @@ describe("QdrantVectorStore", () => { it("should handle complex URL with multiple segments, multiple trailing slashes, query params, and fragment", () => { const complexUrl = "https://example.com/ollama/api/v1///?key=value#pos" - const vectorStoreComplex = new QdrantVectorStore(mockWorkspacePath, complexUrl, mockVectorSize) + const vectorStoreComplex = new QdrantVectorStore( + mockWorkspacePath, + complexUrl, + mockVectorSize, + undefined, + pacers, + ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "example.com", https: true, @@ -499,6 +588,8 @@ describe("QdrantVectorStore", () => { mockWorkspacePath, "http://localhost:6333/api/path?key=value#fragment", mockVectorSize, + undefined, + pacers, ) expect(QdrantClient).toHaveBeenLastCalledWith({ host: "localhost", @@ -690,43 +781,89 @@ describe("QdrantVectorStore", () => { expect(console.error).toHaveBeenCalledTimes(1) // Only the outer try/catch ;(console.error as any).mockRestore() }) - it("should log but not fail if payload index creation errors occur", async () => { - // Mock successful collection creation - mockQdrantClientInstance.getCollection.mockRejectedValue({ - response: { status: 404 }, - message: "Not found", + describe("CI-QD-01: collection and payload index creation", () => { + const serverError = () => Object.assign(new Error("unavailable"), { status: 503 }) + + beforeEach(() => { + vitest.useFakeTimers() + mockQdrantClientInstance.createPayloadIndex.mockResolvedValue(undefined) + mockQdrantClientInstance.getCollection.mockRejectedValue({ + response: { status: 404 }, + message: "Not found", + }) + mockQdrantClientInstance.createCollection.mockResolvedValue(true) + vitest.spyOn(console, "error").mockImplementation(function () {}) + }) + afterEach(() => { + vitest.useRealTimers() + // clearAllMocks keeps implementations; a persistent rejection here would leak into later tests. + mockQdrantClientInstance.createPayloadIndex.mockReset() }) - mockQdrantClientInstance.createCollection.mockResolvedValue(true as any) - // Mock payload index creation to fail - const indexError = new Error("Index creation failed") - mockQdrantClientInstance.createPayloadIndex.mockRejectedValue(indexError) - vitest.spyOn(console, "warn").mockImplementation(function () {}) // Suppress console.warn + it.each(["createCollection", "createPayloadIndex"] as const)( + "recovers when %s fails twice with a server error", + async (method) => { + mockQdrantClientInstance[method] + .mockRejectedValueOnce(serverError()) + .mockRejectedValueOnce(serverError()) + const pending = vectorStore.initialize() + await vitest.advanceTimersByTimeAsync(1500) + await expect(pending).resolves.toBe(true) + }, + ) - const result = await vectorStore.initialize() + it("rejects when payload index creation exhausts its retries", async () => { + mockQdrantClientInstance.createPayloadIndex.mockRejectedValue(serverError()) + const pending = vectorStore.initialize().catch((error: unknown) => error) + await vitest.advanceTimersByTimeAsync(1500) + expect(await pending).toBeInstanceOf(Error) + expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(3) + }) - // Should still return true since main collection setup succeeded - expect(result).toBe(true) - expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) + it("does not retry a permanent creation failure", async () => { + mockQdrantClientInstance.createCollection.mockRejectedValue( + Object.assign(new Error("bad request"), { status: 400 }), + ) + await expect(vectorStore.initialize()).rejects.toThrow() + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) + }) - // Verify all payload index creations were attempted (6: type + 5 pathSegments) - expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(6) + it("retries the collection recreate after a dimension mismatch", async () => { + mockQdrantClientInstance.getCollection + .mockResolvedValueOnce({ config: { params: { vectors: { size: mockVectorSize + 1 } } } }) + .mockRejectedValueOnce(new Error("Not found")) + mockQdrantClientInstance.deleteCollection.mockResolvedValue(true) + mockQdrantClientInstance.createCollection.mockRejectedValueOnce(serverError()) + vitest.spyOn(console, "warn").mockImplementation(function () {}) + const pending = vectorStore.initialize() + await vitest.advanceTimersByTimeAsync(1000) + await expect(pending).resolves.toBe(true) + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(2) + }) - // Verify warnings were logged for each failed index (now 6) - expect(console.warn).toHaveBeenCalledTimes(6) - // Verify warning for 'type' index - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`Could not create payload index for type`), - indexError.message, - ) - for (let i = 0; i <= 4; i++) { - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`Could not create payload index for pathSegments.${i}`), - indexError.message, - ) - } + it.each([ + ["with Retry-After: 2", 2, 2000], + ["without Retry-After", undefined, 1000], + ])("waits and recovers when collection creation gets a 429 %s", async (_label, retryAfter, waitMs) => { + // The module is automocked, so set the fields the patched client would assign. + const limited = Object.assign(new QdrantClientResourceExhaustedError("Too many requests", "0"), { + message: "Too many requests", + retry_after: retryAfter, + }) + mockQdrantClientInstance.createCollection.mockRejectedValueOnce(limited) + const pending = vectorStore.initialize() + await vitest.advanceTimersByTimeAsync(waitMs - 1) + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(1) + await vitest.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toBe(true) + expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledTimes(2) + }) - ;(console.warn as any).mockRestore() + it("treats an existing payload index as success", async () => { + mockQdrantClientInstance.createPayloadIndex.mockRejectedValue(new Error("index already exists")) + await expect(vectorStore.initialize()).resolves.toBe(true) + expect(mockQdrantClientInstance.createPayloadIndex).toHaveBeenCalledTimes(6) + }) }) it("should throw vectorDimensionMismatch error when deleteCollection fails during recreation", async () => { @@ -904,7 +1041,13 @@ describe("QdrantVectorStore", () => { const newVectorSize = 768 // Create a new vector store with the new dimension - const newVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, newVectorSize, mockApiKey) + const newVectorStore = new QdrantVectorStore( + mockWorkspacePath, + mockQdrantUrl, + newVectorSize, + mockApiKey, + pacers, + ) mockQdrantClientInstance.getCollection .mockResolvedValueOnce({ @@ -1236,7 +1379,11 @@ describe("QdrantVectorStore", () => { mockQdrantClientInstance.upsert.mockRejectedValue(upsertError) vitest.spyOn(console, "error").mockImplementation(function () {}) - await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(upsertError) + // The wrapper adds the classification the writer's retry policy reads; message and cause are unchanged. + await expect(vectorStore.upsertPoints(mockPoints)).rejects.toMatchObject({ + message: "Upsert failed", + cause: upsertError, + }) expect(mockQdrantClientInstance.upsert).toHaveBeenCalledTimes(1) expect(console.error).toHaveBeenCalledWith("Failed to upsert points:", upsertError) diff --git a/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts index c8d4058602..4e68d062e2 100644 --- a/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts +++ b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts @@ -2,6 +2,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { getDefaultModelId, getModelDimension } from "../../../../shared/embeddingModels" import { clearAllMocks } from "../../../../test-utils/reset" import type { CodeIndexConfig } from "../../interfaces/config" +import { ProviderPacerRegistry } from "../../provider-pacer" import { QdrantVectorStore } from "../qdrant-client" import { VectorStoreFactory } from "../vector-store-factory" @@ -12,6 +13,7 @@ vi.mock("../../../../shared/embeddingModels", () => ({ })) describe("VectorStoreFactory", () => { + const pacers = new ProviderPacerRegistry() let factory: VectorStoreFactory let config: CodeIndexConfig @@ -32,45 +34,53 @@ describe("VectorStoreFactory", () => { it.each([undefined, 0])("uses the manual dimension when the built-in dimension is %s", (dimension) => { vi.mocked(getModelDimension).mockReturnValue(dimension) - expect(factory.create(config, "/workspace")).toBeInstanceOf(QdrantVectorStore) - expect(QdrantVectorStore).toHaveBeenCalledWith("/workspace", config.qdrantUrl, 512, undefined) + expect(factory.create(config, "/workspace", pacers)).toBeInstanceOf(QdrantVectorStore) + expect(QdrantVectorStore).toHaveBeenCalledWith("/workspace", config.qdrantUrl, 512, undefined, pacers) }) it("does not override an invalid negative built-in dimension with the manual dimension", () => { vi.mocked(getModelDimension).mockReturnValue(-1) - expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(() => factory.create(config, "/workspace", pacers)).toThrow( + "serviceFactory.vectorDimensionNotDetermined", + ) expect(QdrantVectorStore).not.toHaveBeenCalled() }) it.each([undefined, 0, -1])("rejects an unavailable or invalid manual dimension: %s", (dimension) => { config = { ...config, modelDimension: dimension } - expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(() => factory.create(config, "/workspace", pacers)).toThrow( + "serviceFactory.vectorDimensionNotDetermined", + ) expect(QdrantVectorStore).not.toHaveBeenCalled() }) it("rejects Semble before resolving dimensions or creating a store", () => { config = { ...config, embedderProvider: "semble" } - expect(() => factory.create(config, "/workspace")).toThrow("Semble provider handles its own vector storage") + expect(() => factory.create(config, "/workspace", pacers)).toThrow( + "Semble provider handles its own vector storage", + ) expect(getModelDimension).not.toHaveBeenCalled() expect(getDefaultModelId).not.toHaveBeenCalled() expect(QdrantVectorStore).not.toHaveBeenCalled() }) it("uses current configuration and workspace on every call without caching stores", () => { - const first = factory.create(config, "/first") - const second = factory.create({ ...config, modelDimension: 1024 }, "/second") + const first = factory.create(config, "/first", pacers) + const second = factory.create({ ...config, modelDimension: 1024 }, "/second", pacers) expect(first).not.toBe(second) - expect(QdrantVectorStore).toHaveBeenNthCalledWith(1, "/first", config.qdrantUrl, 512, undefined) - expect(QdrantVectorStore).toHaveBeenNthCalledWith(2, "/second", config.qdrantUrl, 1024, undefined) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(1, "/first", config.qdrantUrl, 512, undefined, pacers) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(2, "/second", config.qdrantUrl, 1024, undefined, pacers) }) it("reports the dimension error before a missing Qdrant URL", () => { config = { ...config, modelDimension: undefined, qdrantUrl: undefined } - expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(() => factory.create(config, "/workspace", pacers)).toThrow( + "serviceFactory.vectorDimensionNotDetermined", + ) }) }) diff --git a/src/services/code-index/vector-store/qdrant-client.ts b/src/services/code-index/vector-store/qdrant-client.ts index 12c92f1aad..084b7c0b31 100644 --- a/src/services/code-index/vector-store/qdrant-client.ts +++ b/src/services/code-index/vector-store/qdrant-client.ts @@ -1,4 +1,4 @@ -import { QdrantClient, Schemas } from "@qdrant/js-client-rest" +import { QdrantClient, QdrantClientResourceExhaustedError, Schemas } from "@qdrant/js-client-rest" import { createHash } from "crypto" import * as path from "path" import { v5 as uuidv5 } from "uuid" @@ -6,6 +6,24 @@ import { IVectorStore } from "../interfaces/vector-store" import { Payload, VectorStoreSearchResult } from "../interfaces" import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE, QDRANT_CODE_BLOCK_NAMESPACE } from "../constants" import { t } from "../../../i18n" +import { IndexingRetryError, VectorStoreError } from "../shared/indexing-errors" +import { type ProviderPacerRegistry, makeRateLimitScope } from "../provider-pacer" +import { retryWithPolicy } from "../shared/retry-policy" + +// The patched client reports Retry-After in whole seconds on retry_after (patches/@qdrant__js-client-rest@1.20.0.patch). +function toVectorStoreError(error: unknown): VectorStoreError { + if (error instanceof VectorStoreError) return error + const message = error instanceof Error ? error.message : String(error) + if (error instanceof QdrantClientResourceExhaustedError) { + const seconds = Number(error.retry_after) + return new VectorStoreError(message, error, { + classification: "rate-limit", + statusCode: 429, + retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined, + }) + } + return new VectorStoreError(message, error) +} /** * Qdrant implementation of the vector store interface @@ -18,13 +36,21 @@ export class QdrantVectorStore implements IVectorStore { private readonly collectionName: string private readonly qdrantUrl: string = "http://localhost:6333" private readonly workspacePath: string + readonly rateLimitScope: string /** * Creates a new Qdrant vector store * @param workspacePath Path to the workspace * @param url Optional URL to the Qdrant server + * @param pacers Shared rate-limit waits for collection and index creation */ - constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) { + constructor( + workspacePath: string, + url: string, + vectorSize: number, + apiKey: string | undefined, + private readonly pacers: ProviderPacerRegistry, + ) { // Parse the URL to determine the appropriate QdrantClient configuration const parsedUrl = this.parseQdrantUrl(url) @@ -81,6 +107,25 @@ export class QdrantVectorStore implements IVectorStore { const hash = createHash("sha256").update(workspacePath).digest("hex") this.vectorSize = vectorSize this.collectionName = `ws-${hash.substring(0, 16)}` + this.rateLimitScope = makeRateLimitScope("qdrant", this.qdrantUrl) + } + + /** Runs one creation request with bounded server/connection retries and the shared rate-limit wait. */ + private async create(request: () => Promise, signal?: AbortSignal): Promise { + // Only the 429 is converted; other errors keep initialize()'s connection and dimension-mismatch messages. + const classified = () => + request().catch((error: unknown) => { + throw error instanceof QdrantClientResourceExhaustedError ? toVectorStoreError(error) : error + }) + try { + return await retryWithPolicy( + () => this.pacers.get(this.rateLimitScope).run(undefined, signal, classified), + { signal, budget: { retriesUsed: 0 } }, + ) + } catch (error) { + // The original error keeps initialize()'s existing connection and dimension-mismatch messages. + throw error instanceof IndexingRetryError ? error.cause : error + } } /** @@ -144,27 +189,32 @@ export class QdrantVectorStore implements IVectorStore { /** * Initializes the vector store + * @param signal Ends retry and rate-limit waits during collection and index creation * @returns Promise resolving to boolean indicating if a new collection was created */ - async initialize(): Promise { + async initialize(signal?: AbortSignal): Promise { let created = false try { const collectionInfo = await this.getCollectionInfo() if (collectionInfo === null) { // Collection info not retrieved (assume not found or inaccessible), create it - await this.client.createCollection(this.collectionName, { - vectors: { - size: this.vectorSize, - distance: this.DISTANCE_METRIC, - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) + await this.create( + () => + this.client.createCollection(this.collectionName, { + vectors: { + size: this.vectorSize, + distance: this.DISTANCE_METRIC, + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }), + signal, + ) created = true } else { // Collection exists, check vector size @@ -188,14 +238,15 @@ export class QdrantVectorStore implements IVectorStore { created = false // Exists and correct } else { // Exists but wrong vector size, recreate with enhanced error handling - created = await this._recreateCollectionWithNewDimension(existingVectorSize) + created = await this._recreateCollectionWithNewDimension(existingVectorSize, signal) } } // Create payload indexes - await this._createPayloadIndexes() + await this._createPayloadIndexes(signal) return created } catch (error: any) { + if (signal?.aborted) throw error const errorMessage = error?.message || error console.error( `[QdrantVectorStore] Failed to initialize Qdrant collection "${this.collectionName}":`, @@ -219,7 +270,10 @@ export class QdrantVectorStore implements IVectorStore { * @param existingVectorSize The current vector size of the existing collection * @returns Promise resolving to boolean indicating if a new collection was created */ - private async _recreateCollectionWithNewDimension(existingVectorSize: number): Promise { + private async _recreateCollectionWithNewDimension( + existingVectorSize: number, + signal?: AbortSignal, + ): Promise { console.warn( `[QdrantVectorStore] Collection ${this.collectionName} exists with vector size ${existingVectorSize}, but expected ${this.vectorSize}. Recreating collection.`, ) @@ -248,18 +302,22 @@ export class QdrantVectorStore implements IVectorStore { `[QdrantVectorStore] Creating new collection ${this.collectionName} with vector size ${this.vectorSize}...`, ) recreationAttempted = true - await this.client.createCollection(this.collectionName, { - vectors: { - size: this.vectorSize, - distance: this.DISTANCE_METRIC, - on_disk: true, - }, - hnsw_config: { - m: 64, - ef_construct: 512, - on_disk: true, - }, - }) + await this.create( + () => + this.client.createCollection(this.collectionName, { + vectors: { + size: this.vectorSize, + distance: this.DISTANCE_METRIC, + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }), + signal, + ) console.log(`[QdrantVectorStore] Successfully created new collection ${this.collectionName}`) return true } catch (recreationError) { @@ -293,41 +351,23 @@ export class QdrantVectorStore implements IVectorStore { } /** - * Creates payload indexes for the collection, handling errors gracefully. + * Creates payload indexes. An existing index counts as success; any other failure that outlasts the + * retry policy rejects, so initialization is never reported as successful without its indexes. */ - private async _createPayloadIndexes(): Promise { - // Create index for the 'type' field to enable metadata filtering - try { - await this.client.createPayloadIndex(this.collectionName, { - field_name: "type", - field_schema: "keyword", - }) - } catch (indexError: any) { - const errorMessage = (indexError?.message || "").toLowerCase() - if (!errorMessage.includes("already exists")) { - console.warn( - `[QdrantVectorStore] Could not create payload index for type on ${this.collectionName}. Details:`, - indexError?.message || indexError, - ) - } - } - - // Create indexes for pathSegments fields - for (let i = 0; i <= 4; i++) { - try { - await this.client.createPayloadIndex(this.collectionName, { - field_name: `pathSegments.${i}`, - field_schema: "keyword", - }) - } catch (indexError: any) { - const errorMessage = (indexError?.message || "").toLowerCase() - if (!errorMessage.includes("already exists")) { - console.warn( - `[QdrantVectorStore] Could not create payload index for pathSegments.${i} on ${this.collectionName}. Details:`, - indexError?.message || indexError, - ) + private async _createPayloadIndexes(signal?: AbortSignal): Promise { + const fields = ["type", ...Array.from({ length: 5 }, (_unused, index) => `pathSegments.${index}`)] + for (const fieldName of fields) { + await this.create(async () => { + try { + await this.client.createPayloadIndex(this.collectionName, { + field_name: fieldName, + field_schema: "keyword", + }) + } catch (indexError) { + const message = indexError instanceof Error ? indexError.message.toLowerCase() : "" + if (!message.includes("already exists")) throw indexError } - } + }, signal) } } @@ -370,7 +410,7 @@ export class QdrantVectorStore implements IVectorStore { }) } catch (error) { console.error("Failed to upsert points:", error) - throw error + throw toVectorStoreError(error) } } @@ -463,7 +503,7 @@ export class QdrantVectorStore implements IVectorStore { return filteredPoints as VectorStoreSearchResult[] } catch (error) { console.error("Failed to search points:", error) - throw error + throw error instanceof QdrantClientResourceExhaustedError ? toVectorStoreError(error) : error } } @@ -536,6 +576,8 @@ export class QdrantVectorStore implements IVectorStore { // Include first few file paths for debugging (avoid logging too many) samplePaths: filePaths.slice(0, 3), }) + // A swallowed failure would let callers clear the file hash and lose the stale entries. + throw toVectorStoreError(error) } } diff --git a/src/services/code-index/vector-store/vector-store-factory.ts b/src/services/code-index/vector-store/vector-store-factory.ts index 174f217108..e3afe84d16 100644 --- a/src/services/code-index/vector-store/vector-store-factory.ts +++ b/src/services/code-index/vector-store/vector-store-factory.ts @@ -4,9 +4,10 @@ import type { CodeIndexConfig } from "../interfaces/config" import type { IVectorStore } from "../interfaces" import type { IVectorStoreFactory } from "../interfaces/vector-store-factory" import { QdrantVectorStore } from "./qdrant-client" +import type { ProviderPacerRegistry } from "../provider-pacer" export class VectorStoreFactory implements IVectorStoreFactory { - public create(config: CodeIndexConfig, workspacePath: string): IVectorStore { + public create(config: CodeIndexConfig, workspacePath: string, pacers: ProviderPacerRegistry): IVectorStore { if (config.embedderProvider === "semble") { throw new Error( "Semble provider handles its own vector storage. Do not call createVectorStore() for semble — use SembleProvider instead.", @@ -19,7 +20,7 @@ export class VectorStoreFactory implements IVectorStoreFactory { throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) } - return new QdrantVectorStore(workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) + return new QdrantVectorStore(workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey, pacers) } private resolveVectorSize(config: CodeIndexConfig): number { diff --git a/src/services/code-index/workspace-indexing-enablement-manager.ts b/src/services/code-index/workspace-indexing-enablement-manager.ts index 78f179ad4a..6edca361d8 100644 --- a/src/services/code-index/workspace-indexing-enablement-manager.ts +++ b/src/services/code-index/workspace-indexing-enablement-manager.ts @@ -25,7 +25,7 @@ export class WorkspaceIndexingEnablementManager { await this.manager.initialize(provider.contextProxy) void this.manager.startIndexing().catch((error) => provider.log(`Indexing error: ${error}`)) } else if (!enabled) { - this.manager.stopIndexing() + await this.manager.stopIndexing() } await provider.postMessageToWebview({ type: "indexingStatusUpdate", diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index e832802dbc..c3b985b653 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -399,6 +399,22 @@ export const stories: Record = { ) }, + ...Object.fromEntries( + ["failed-files", "provider-wait", "amber-badge"].map((variant): [string, Story] => [ + `code-index-status-${variant}`, + async () => { + const [{ AppProviders }, { CodeIndexStatusFixture }] = await Promise.all([ + import("../AppProviders"), + import("@/components/chat/__tests__/CodeIndexStatus.visual.fixture"), + ]) + return ( + + + + ) + }, + ]), + ), "theme-token-cleanup": async () => { const [{ Checkbox }, { enabledChatControlClassName }] = await Promise.all([ import("@/components/ui/checkbox"), diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 62c811e4b0..8b3b76b504 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -15,6 +15,7 @@ import { TriangleAlert } from "lucide-react" import { type IndexingStatus, type EmbedderProvider, + type IndexingStatusUpdateMessage, CODEBASE_INDEX_DEFAULTS, providerIdentifiers, } from "@roo-code/types" @@ -200,12 +201,16 @@ export const CodeIndexPopover: React.FC = ({ }) => { const SECRET_PLACEHOLDER = "••••••••••••••••" const { t } = useAppTranslation() - const { codebaseIndexConfig, codebaseIndexModels, cwd, apiConfiguration, platform, arch } = useExtensionState() + const { codebaseIndexConfig, codebaseIndexModels, cwd, apiConfiguration, platform, arch, language } = + useExtensionState() const [open, setOpen] = useState(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false) const [isSetupSettingsOpen, setIsSetupSettingsOpen] = useState(false) const [indexingStatus, setIndexingStatus] = useState(externalIndexingStatus) + const failedFileSummary = indexingStatus.failedFileSummary + const failedFiles = indexingStatus.failedFiles ?? [] + const hasFailedFiles = failedFiles.length > 0 || (failedFileSummary?.count ?? 0) > 0 const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") const [saveError, setSaveError] = useState(null) @@ -318,16 +323,14 @@ export const CodeIndexPopover: React.FC = ({ // Listen for indexing status updates and save responses useEffect(() => { - const handleMessage = (event: MessageEvent) => { + const handleMessage = ( + event: MessageEvent< + IndexingStatusUpdateMessage | { type: "codeIndexSettingsSaved"; success: boolean; error?: string } + >, + ) => { if (event.data.type === "indexingStatusUpdate") { if (!event.data.values.workspacePath || event.data.values.workspacePath === cwd) { - setIndexingStatus({ - systemStatus: event.data.values.systemStatus, - message: event.data.values.message || "", - processedItems: event.data.values.processedItems, - totalItems: event.data.values.totalItems, - currentItemUnit: event.data.values.currentItemUnit || "items", - }) + setIndexingStatus(event.data.values) } } else if (event.data.type === "codeIndexSettingsSaved") { if (event.data.success) { @@ -671,16 +674,91 @@ export const CodeIndexPopover: React.FC = ({
{t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} {indexingStatus.message ? ` - ${indexingStatus.message}` : ""}
+ {indexingStatus.providerWaits?.map((wait, index) => ( +
+

+ {t( + indexingStatus.systemStatus === "Indexing" + ? wait.resumeAt === undefined + ? "settings:codeIndex.providerWaiting" + : "settings:codeIndex.providerWaitingUntil" + : wait.resumeAt === undefined + ? "settings:codeIndex.providerPaused" + : "settings:codeIndex.providerPausedUntil", + { + service: wait.service, + time: + wait.resumeAt === undefined + ? undefined + : new Date(wait.resumeAt).toLocaleString(language), + }, + )} +

+
    + {wait.waitingFiles.map((file) => ( +
  • {file}
  • + ))} +
+
+ ))} + + {hasFailedFiles && ( +
+ {failedFileSummary && failedFileSummary.count > 0 && ( +

+ {t( + failedFileSummary.state === "retrying" + ? "settings:codeIndex.indexingFilesRetrying" + : "settings:codeIndex.indexingFilesFailed", + { ...failedFileSummary }, + )} +

+ )} +
+ + {t("settings:codeIndex.failedFileDetails")} ({failedFiles.length}) + +
    + {failedFiles.map((failedFile) => ( +
  • +
    + {failedFile.path} +
    +
    + {t( + `settings:codeIndex.failedFileActivities.${failedFile.activity}`, + )} +
    +
    + {t(`settings:codeIndex.failedFileStates.${failedFile.state}`)} +
    +
    + {t("settings:codeIndex.failedFileReason")}: {failedFile.reason} +
    +
  • + ))} +
+
+
+ )} + {indexingStatus.systemStatus === "Indexing" && (
= ({
{currentSettings.codebaseIndexEnabled && - (indexingStatus.systemStatus === "Error" || + (hasFailedFiles || + indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && ( )} diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx index 22dbe8f9d5..d49aefb8c6 100644 --- a/webview-ui/src/components/chat/IndexingStatusBadge.tsx +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -57,7 +57,10 @@ export const IndexingStatusBadge: React.FC = ({ classN [indexingStatus.processedItems, indexingStatus.totalItems], ) - const tooltipText = useMemo(() => { + const hasFailedFiles = + (indexingStatus.failedFiles?.length ?? 0) > 0 || (indexingStatus.failedFileSummary?.count ?? 0) > 0 + + const statusTooltip = useMemo(() => { switch (indexingStatus.systemStatus) { case "Standby": return t("chat:indexingStatus.ready") @@ -74,7 +77,32 @@ export const IndexingStatusBadge: React.FC = ({ classN } }, [indexingStatus.systemStatus, progressPercentage, t]) + const tooltipText = useMemo(() => { + if (!hasFailedFiles || indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Stopping") { + return statusTooltip + } + + const summary = indexingStatus.failedFileSummary + const failureText = + summary && summary.count > 0 + ? t( + summary.state === "retrying" + ? "settings:codeIndex.indexingFilesRetrying" + : "settings:codeIndex.indexingFilesFailed", + { ...summary }, + ) + : `${t("settings:codeIndex.failedFileDetails")} (${indexingStatus.failedFiles?.length ?? 0})` + return `${statusTooltip} - ${failureText}` + }, [hasFailedFiles, indexingStatus, statusTooltip, t]) + const statusColorClass = useMemo(() => { + if ( + hasFailedFiles && + (indexingStatus.systemStatus === "Standby" || indexingStatus.systemStatus === "Indexed") + ) { + return "bg-amber-500" + } + const statusColors = { Standby: "bg-vscode-descriptionForeground/60", Indexing: "bg-yellow-500 animate-pulse", @@ -84,7 +112,7 @@ export const IndexingStatusBadge: React.FC = ({ classN } return statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby - }, [indexingStatus.systemStatus]) + }, [hasFailedFiles, indexingStatus.systemStatus]) return ( diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.test.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.test.tsx index e27a10d51c..5fac9ee3ac 100644 --- a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.test.tsx +++ b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.test.tsx @@ -1,13 +1,36 @@ import React from "react" +import { createInstance } from "i18next" +import englishSettings from "@/i18n/locales/en/settings.json" +import germanSettings from "@/i18n/locales/de/settings.json" -import type { EmbedderProvider, ExtensionState } from "@roo-code/types" +import type { + EmbedderProvider, + ExtensionState, + FailedFileStatus, + IndexingStatus, + IndexingStatusUpdateMessage, +} from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types" -import { fireEvent, renderWithExtensionState, screen } from "@/utils/test-utils" +import { act, fireEvent, renderWithExtensionState, screen, within } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" import { useOpenRouterModelProviders } from "@src/components/ui/hooks/useOpenRouterModelProviders" import { CodeIndexPopover, createValidationSchema } from "../CodeIndexPopover" +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, parameters?: Record) => + key === "settings:codeIndex.indexingFilesRetrying" || key === "settings:codeIndex.indexingFilesFailed" + ? `${key}: ${parameters?.count} / ${parameters?.path} / ${parameters?.reason}` + : key.startsWith("settings:codeIndex.provider") + ? translation.t(key, parameters) + : key, + }), +})) + vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto", useOpenRouterModelProviders: vi.fn(() => ({ data: undefined })), @@ -43,8 +66,8 @@ vi.mock("@src/components/ui", async (importOriginal) => { } }) -const indexingStatus = { - systemStatus: "Standby" as const, +const indexingStatus: IndexingStatus = { + systemStatus: "Standby", message: "", processedItems: 0, totalItems: 0, @@ -58,6 +81,15 @@ const baseSettings = { } const translate = (key: string) => key +const translation = createInstance() + +beforeAll(async () => { + await translation.init({ + lng: "en", + resources: { en: { settings: englishSettings }, de: { settings: germanSettings } }, + interpolation: { escapeValue: false }, + }) +}) describe("CodeIndexPopover validation", () => { it.each([ @@ -228,3 +260,270 @@ describe("CodeIndexPopover OpenRouter provider lookup", () => { expect(screen.getByText("settings:codeIndex.validation.geminiApiKeyRequired")).toBeInTheDocument() }) }) + +const failedStatus = ( + state: FailedFileStatus["state"], + systemStatus = state === "retrying" ? "Indexing" : "Standby", +): IndexingStatus => { + const failure: FailedFileStatus = { + path: "src/failed.ts", + activity: "embed", + reason: "Provider unavailable", + state, + } + return { + ...indexingStatus, + systemStatus, + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: false, + processedItems: 2, + totalItems: 8, + failedFiles: [failure], + failedFileSummary: { ...failure, count: 1 }, + } +} + +const renderStatusPopover = (status: IndexingStatus, language: ExtensionState["language"] = "en") => + renderWithExtensionState( + + + , + { + state: { + language, + cwd: "/workspace", + codebaseIndexConfig: { ...baseSettings, codebaseIndexEmbedderProvider: providerIdentifiers.openai }, + codebaseIndexModels: {}, + platform: "win32", + arch: "x64", + }, + }, + ) + +const publishStatus = (values: IndexingStatus) => { + const data: IndexingStatusUpdateMessage = { type: "indexingStatusUpdate", values } + act(() => window.dispatchEvent(new MessageEvent("message", { data }))) +} + +describe("CodeIndexPopover indexing reliability", () => { + beforeEach(async () => { + vi.clearAllMocks() + await translation.changeLanguage("en") + }) + + it.each(["Indexing", "Indexed", "Standby", "Stopping", "Error"])( + "localizes timed and untimed waits without false resume promises in %s", + async (systemStatus) => { + await translation.changeLanguage("de") + const resumeAt = Date.parse("2026-11-11T12:02:00Z") + renderStatusPopover( + { + ...indexingStatus, + systemStatus, + providerWaits: [ + { service: "openai", resumeAt, waitingFiles: ["waiting.ts"] }, + { service: "qdrant", waitingFiles: [] }, + ], + }, + "de", + ) + const active = systemStatus === "Indexing" + const timed = active ? "providerWaitingUntil" : "providerPausedUntil" + const untimed = active ? "providerWaiting" : "providerPaused" + expect( + screen.getByText( + translation.t(`settings:codeIndex.${timed}`, { + service: "openai", + time: new Date(resumeAt).toLocaleString("de"), + }), + ), + ).toBeVisible() + expect( + screen.getByText(translation.t(`settings:codeIndex.${untimed}`, { service: "qdrant" })), + ).toBeVisible() + expect(screen.queryByText(/Waiting for/)).not.toBeInTheDocument() + if (!active) expect(screen.queryByText(/automatisch fortgesetzt/)).not.toBeInTheDocument() + }, + ) + + it("keeps two provider waits separate from genuine failures and Stop enabled", () => { + renderStatusPopover({ + ...failedStatus("retrying"), + providerWaits: [ + { service: "openai", resumeAt: Date.now() + 120_000, waitingFiles: ["src/waiting-embed.ts"] }, + { service: "qdrant", waitingFiles: ["src/waiting-store.ts"] }, + ], + }) + expect(screen.getByText(/Waiting for openai; indexing resumes automatically at/)).toBeVisible() + expect(screen.getByText("Waiting for qdrant; indexing resumes automatically")).toBeVisible() + for (const path of ["src/waiting-embed.ts", "src/waiting-store.ts"]) { + expect(screen.getByText(path)).toBeVisible() + expect(screen.getByRole("status")).not.toHaveTextContent(path) + } + expect(screen.getByRole("status")).toHaveTextContent("1 / src/failed.ts") + const stop = screen.getByRole("button", { name: "settings:codeIndex.stopIndexingButton" }) + expect(stop).toBeEnabled() + fireEvent.click(stop) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "stopIndexing" }) + publishStatus({ ...indexingStatus, providerWaits: [], failedFiles: [] }) + expect(screen.queryByText(/Waiting for/)).not.toBeInTheDocument() + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + + it.each(["empty", "omitted"])("clears waiting-only snapshots when waits are %s", (snapshot) => { + renderStatusPopover({ + ...indexingStatus, + systemStatus: "Indexing", + providerWaits: [{ service: "openai", waitingFiles: ["waiting.ts"] }], + }) + expect(screen.getByText("waiting.ts")).toBeVisible() + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.queryByText(/settings:codeIndex.failedFileDetails/)).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "settings:codeIndex.startIndexingButton" })).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "settings:codeIndex.stopIndexingButton" })).toBeEnabled() + publishStatus({ ...indexingStatus, providerWaits: snapshot === "empty" ? [] : undefined }) + expect(screen.queryByText("waiting.ts")).not.toBeInTheDocument() + expect(screen.queryByText(/Waiting for/)).not.toBeInTheDocument() + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "settings:codeIndex.startIndexingButton" })).toBeEnabled() + }) + + it("replaces waits from parent snapshots and ignores waits for other workspaces", () => { + const view = renderStatusPopover({ + ...indexingStatus, + providerWaits: [{ service: "openai", waitingFiles: ["waiting.ts"] }], + }) + publishStatus({ ...indexingStatus, workspacePath: "/other", providerWaits: [] }) + expect(screen.getByText("waiting.ts")).toBeVisible() + view.rerender( + + + , + ) + expect(screen.queryByText("waiting.ts")).not.toBeInTheDocument() + }) + + it.each(["retrying", "exhausted"] as const)("shows %s summary and expandable file details", (state) => { + renderStatusPopover(failedStatus(state)) + const summaryKey = state === "retrying" ? "indexingFilesRetrying" : "indexingFilesFailed" + expect(screen.getByRole("status")).toHaveTextContent( + `settings:codeIndex.${summaryKey}: 1 / src/failed.ts / Provider unavailable`, + ) + const summary = screen.getByText("settings:codeIndex.failedFileDetails (1)") + const details = summary.closest("details")! + expect(details).not.toHaveAttribute("open") + fireEvent.click(summary) + expect(within(details).getByText("src/failed.ts")).toBeInTheDocument() + expect(within(details).getByText("settings:codeIndex.failedFileActivities.embed")).toBeInTheDocument() + expect(within(details).getByText(`settings:codeIndex.failedFileStates.${state}`)).toBeInTheDocument() + expect( + within(details).getByText("settings:codeIndex.failedFileReason: Provider unavailable"), + ).toBeInTheDocument() + }) + + it("renders all five failure activities and both retry states", () => { + const activities: FailedFileStatus["activity"][] = ["read", "parse", "embed", "upsert", "delete"] + const failedFiles: FailedFileStatus[] = activities.map((activity, index) => ({ + path: `src/${activity}.ts`, + activity, + reason: `${activity} failed`, + state: index % 2 === 0 ? "retrying" : "exhausted", + })) + renderStatusPopover({ ...failedStatus("retrying"), failedFiles, failedFileSummary: undefined }) + const details = screen.getByText("settings:codeIndex.failedFileDetails (5)").closest("details")! + fireEvent.click(within(details).getByText("settings:codeIndex.failedFileDetails (5)")) + for (const failure of failedFiles) { + const entry = within(details).getByText(failure.path).closest("li")! + expect( + within(entry).getByText(`settings:codeIndex.failedFileActivities.${failure.activity}`), + ).toBeInTheDocument() + expect(within(entry).getByText(`settings:codeIndex.failedFileStates.${failure.state}`)).toBeInTheDocument() + expect( + within(entry).getByText(`settings:codeIndex.failedFileReason: ${failure.reason}`), + ).toBeInTheDocument() + } + }) + + it("allows explicit Start for idle partial Standby", () => { + renderStatusPopover(failedStatus("exhausted")) + const start = screen.getByRole("button", { name: "settings:codeIndex.startIndexingButton" }) + expect(start).toBeEnabled() + fireEvent.click(start) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "startIndexing" }) + }) + + it.each(["Indexing", "Stopping"])("keeps Start discoverable but disabled during %s", (systemStatus) => { + renderStatusPopover(failedStatus("retrying", systemStatus)) + const start = screen.getByRole("button", { name: "settings:codeIndex.startIndexingButton" }) + expect(start).toBeDisabled() + fireEvent.click(start) + expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "startIndexing" }) + if (systemStatus === "Stopping") { + expect(screen.getByRole("button", { name: "settings:codeIndex.stoppingButton" })).toBeDisabled() + } else { + fireEvent.click(screen.getByRole("button", { name: "settings:codeIndex.stopIndexingButton" })) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "stopIndexing" }) + } + }) + + it.each([ + ["Indexing", "Embedding batch 2 of 8"], + ["Error", "Parser timeout at src/hung.ts. Start indexing to retry or disable indexing. Not a provider error."], + ["Stopping", "Stopping parser and pending retries"], + ["Standby", "Indexing stopped. Partial index data preserved."], + ])("preserves the backend %s message alongside failures", (systemStatus, message) => { + renderStatusPopover({ ...failedStatus("exhausted", systemStatus), message }) + expect( + screen.getByText(`settings:codeIndex.indexingStatuses.${systemStatus.toLowerCase()} - ${message}`), + ).toBeInTheDocument() + expect(screen.getByRole("status")).toHaveTextContent("settings:codeIndex.indexingFilesFailed") + if (systemStatus === "Indexing") { + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "25") + } + }) + + it.each([{ failedFiles: undefined }, { failedFiles: [] }])( + "omits failure UI for zero failures: %s", + ({ failedFiles }) => { + renderStatusPopover({ + ...failedStatus("exhausted", "Indexed"), + failedFiles, + failedFileSummary: { ...failedStatus("exhausted").failedFileSummary!, count: 0 }, + }) + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.queryByText(/settings:codeIndex.failedFileDetails/)).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "settings:codeIndex.startIndexingButton" }), + ).not.toBeInTheDocument() + }, + ) + + it("preserves whole status messages and filters other workspaces", () => { + renderStatusPopover(indexingStatus) + const status = { ...failedStatus("exhausted"), message: "Partial index available", autoEnableDefault: true } + publishStatus(status) + expect(screen.getByText("settings:codeIndex.failedFileDetails (1)")).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: "settings:codeIndex.workspaceToggleLabel" })).toBeChecked() + expect(screen.getByRole("checkbox", { name: "settings:codeIndex.autoEnableDefaultLabel" })).toBeChecked() + publishStatus({ ...indexingStatus, systemStatus: "Error", message: "Wrong workspace", workspacePath: "/other" }) + expect(screen.queryByText(/Wrong workspace/)).not.toBeInTheDocument() + expect(screen.getByText(/Partial index available/)).toBeInTheDocument() + publishStatus({ ...indexingStatus, systemStatus: "Indexed" }) + expect(screen.queryByText(/settings:codeIndex.failedFileDetails/)).not.toBeInTheDocument() + expect(screen.getByText("settings:codeIndex.indexingStatuses.indexed")).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: "settings:codeIndex.workspaceToggleLabel" })).not.toBeChecked() + }) + + it("accepts whole status updates from the badge prop", () => { + const view = renderStatusPopover(indexingStatus) + view.rerender( + + + , + ) + expect(screen.getByRole("status")).toHaveTextContent("settings:codeIndex.indexingFilesRetrying") + expect(screen.getByRole("checkbox", { name: "settings:codeIndex.workspaceToggleLabel" })).toBeChecked() + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "25") + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.fixture.tsx new file mode 100644 index 0000000000..7fad063c22 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.fixture.tsx @@ -0,0 +1,53 @@ +import { useEffect } from "react" + +import type { IndexingStatus } from "@roo-code/types" + +import { IndexingStatusBadge } from "../IndexingStatusBadge" + +const failedFile = { + path: "src/broken.ts", + activity: "embed", + reason: "Server error after 3 attempts", + state: "exhausted", +} as const + +export const codeIndexStatusFixtures: Record = { + "failed-files": { + systemStatus: "Standby", + processedItems: 41, + totalItems: 42, + currentItemUnit: "blocks", + failedFiles: [failedFile], + failedFileSummary: { ...failedFile, count: 1 }, + }, + "provider-wait": { + systemStatus: "Indexing", + processedItems: 10, + totalItems: 42, + currentItemUnit: "blocks", + providerWaits: [{ service: "OpenAI", waitingFiles: ["src/a.ts", "src/b.ts"] }], + }, + "amber-badge": { + systemStatus: "Indexed", + processedItems: 42, + totalItems: 42, + currentItemUnit: "blocks", + failedFiles: [failedFile], + }, +} + +function StatusFeeder({ status }: { status: IndexingStatus }) { + useEffect(() => { + window.postMessage({ type: "indexingStatusUpdate", values: status }, "*") + }, [status]) + return null +} + +export function CodeIndexStatusFixture({ variant }: { variant: string }) { + return ( +
+ + +
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.tsx new file mode 100644 index 0000000000..78589825eb --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodeIndexStatus.visual.tsx @@ -0,0 +1,23 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" + +const popoverCases = [ + { story: "code-index-status-failed-files", text: /broken\.ts/ }, + { story: "code-index-status-provider-wait", text: /OpenAI/ }, +] + +for (const { story, text } of popoverCases) { + test(`renders the ${story} popover`, async ({ mount, page }) => { + const component = mountedStory(await mount(story)) + await component.getByRole("button").first().click() + const popover = page.getByRole("dialog") + await expect(popover.getByText(text).first()).toBeVisible() + await expect(popover).toHaveScreenshot(`${story}.png`) + }) +} + +test("shows the amber badge for an idle partial index", async ({ mount }) => { + const component = mountedStory(await mount("code-index-status-amber-badge")) + await expect(component.locator(".bg-amber-500")).toBeVisible() + await expect(component).toHaveScreenshot("code-index-status-amber-badge.png") +}) diff --git a/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx index 04ba9cc60e..0ba260b3bb 100644 --- a/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.spec.tsx @@ -1,264 +1,237 @@ import React from "react" -import { render, screen, fireEvent, waitFor, act } from "@/utils/test-utils" - +import type { IndexingStatus, IndexingStatusUpdateMessage } from "@roo-code/types" +import { render, screen, fireEvent, act } from "@/utils/test-utils" import { vscode } from "@src/utils/vscode" - +import { CodeIndexPopover } from "../CodeIndexPopover" import { IndexingStatusBadge } from "../IndexingStatusBadge" -vi.mock("@/i18n/setup", () => ({ - __esModule: true, - default: { - use: vi.fn().mockReturnThis(), - init: vi.fn().mockReturnThis(), - addResourceBundle: vi.fn(), - language: "en", - changeLanguage: vi.fn(), - }, -})) +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, params?: any) => { - const translations: Record = { - "indexingStatus.ready": "Index ready", - "indexingStatus.indexing": params?.progress !== undefined ? `Indexing ${params.progress}%` : "Indexing", - "indexingStatus.error": "Index error", - "indexingStatus.indexed": "Indexed", - "indexingStatus.tooltip.ready": "The codebase index is ready for use", - "indexingStatus.tooltip.indexing": - params?.progress !== undefined - ? `Indexing in progress: ${params.progress}% complete` - : "Indexing in progress", - "indexingStatus.tooltip.error": "An error occurred during indexing", - "indexingStatus.tooltip.indexed": "Codebase has been successfully indexed", - "indexingStatus.tooltip.clickToSettings": "Click to open indexing settings", - } - return translations[key] || key - }, - i18n: { - language: "en", - changeLanguage: vi.fn(), - }, - }), - initReactI18next: { - type: "3rdParty", - init: vi.fn(), - }, - Trans: ({ children }: { children: React.ReactNode }) => <>{children}, +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ cwd: "/workspace" }), + ExtensionStateContextProvider: ({ children }: React.PropsWithChildren) => <>{children}, })) -// Mock vscode API -vi.mock("@src/utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, +vi.mock("../CodeIndexPopover", () => ({ + CodeIndexPopover: vi.fn(({ children }: React.PropsWithChildren<{ indexingStatus: IndexingStatus }>) => ( + <>{children} + )), })) -// Mock the ExtensionStateContext -vi.mock("@/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - language: "en", - }), - ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, -})) +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + PopoverTrigger: ({ children }: React.PropsWithChildren) => <>{children}, + StandardTooltip: ({ children }: React.PropsWithChildren) => <>{children}, + } +}) -// Mock TranslationContext to provide t function directly vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string, params?: any) => { - // Remove namespace prefix if present - const cleanKey = key.includes(":") ? key.split(":")[1] : key - + t: (key: string, parameters?: Record) => { const translations: Record = { - "indexingStatus.ready": "Index ready", - "indexingStatus.indexing": - params?.percentage !== undefined ? `Indexing ${params.percentage}%` : "Indexing", - "indexingStatus.error": "Index error", - "indexingStatus.indexed": "Indexed", - "indexingStatus.tooltip.ready": "The codebase index is ready for use", - "indexingStatus.tooltip.indexing": - params?.percentage !== undefined - ? `Indexing in progress: ${params.percentage}% complete` - : "Indexing in progress", - "indexingStatus.tooltip.error": "An error occurred during indexing", - "indexingStatus.tooltip.indexed": "Codebase has been successfully indexed", - "indexingStatus.tooltip.clickToSettings": "Click to open indexing settings", + "chat:indexingStatus.ready": "Index ready", + "chat:indexingStatus.indexing": `Indexing ${parameters?.percentage}%`, + "chat:indexingStatus.indexed": "Indexed", + "chat:indexingStatus.error": "Index error", + "chat:indexingStatus.stopping": "Stopping indexing...", + "chat:indexingStatus.status": "Index status", + "settings:codeIndex.indexingFilesRetrying": `Retrying ${parameters?.count}: ${parameters?.path}: ${parameters?.reason}`, + "settings:codeIndex.indexingFilesFailed": `Failed ${parameters?.count}: ${parameters?.path}: ${parameters?.reason}`, + "settings:codeIndex.failedFileDetails": "Failed file details", } - return translations[cleanKey] || cleanKey + return translations[key] ?? key }, }), })) -describe("IndexingStatusBadge", () => { - const renderComponent = (props = {}) => { - return render() +const baseStatus: IndexingStatus = { + systemStatus: "Standby", + processedItems: 0, + totalItems: 0, + currentItemUnit: "files", +} + +const failureStatus = (state: "retrying" | "exhausted"): IndexingStatus => { + const failure = { path: "src/failed.ts", activity: "embed", reason: "Provider unavailable", state } as const + return { + ...baseStatus, + systemStatus: state === "retrying" ? "Indexing" : "Standby", + processedItems: 25, + totalItems: 100, + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: false, + message: "Backend status message", + failedFiles: [failure], + failedFileSummary: { ...failure, count: 1 }, } +} - beforeEach(() => { - vi.clearAllMocks() - }) - - it("renders the status dot", () => { - renderComponent() - const button = screen.getByRole("button") - expect(button).toBeInTheDocument() - }) - - it("shows standby status by default", () => { - renderComponent() - const button = screen.getByRole("button") - expect(button).toHaveAttribute("aria-label", "Index ready") - }) - - it("opens popover when clicked", () => { - // Mock window.postMessage - const postMessageSpy = vi.spyOn(window, "postMessage") +const publishStatus = (values: IndexingStatus) => { + const data: IndexingStatusUpdateMessage = { type: "indexingStatusUpdate", values } + act(() => window.dispatchEvent(new MessageEvent("message", { data }))) +} - renderComponent() +const statusDot = () => screen.getByRole("button").querySelector("span") - const button = screen.getByRole("button") - fireEvent.click(button) +const lastPopoverStatus = () => vi.mocked(CodeIndexPopover).mock.calls.at(-1)?.[0].indexingStatus - // The button should be clickable and not post a message anymore - // Since the popover is rendered conditionally, we just verify the button is clickable - // and doesn't throw any errors. The actual popover rendering is tested in integration tests. - expect(postMessageSpy).not.toHaveBeenCalled() +describe("IndexingStatusBadge", () => { + beforeEach(() => vi.clearAllMocks()) - postMessageSpy.mockRestore() + it("renders an accessible, clickable standby control and requests initial status", () => { + render() + fireEvent.click(screen.getByRole("button", { name: "Index ready" })) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "requestIndexingStatus" }) + expect(statusDot()).toHaveClass("bg-vscode-descriptionForeground/60") }) - it("requests indexing status on mount", () => { - renderComponent() - - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "requestIndexingStatus", - }) + it.each([ + ["Standby", "Index ready", "bg-vscode-descriptionForeground/60"], + ["Indexed", "Indexed", "bg-green-500"], + ["Error", "Index error", "bg-red-500"], + ["Stopping", "Stopping indexing...", "bg-amber-500"], + ["Unknown", "Index status", "bg-vscode-descriptionForeground/60"], + ])("preserves %s status without failures", (systemStatus, label, color) => { + render() + publishStatus({ ...baseStatus, systemStatus }) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", label) + expect(statusDot()).toHaveClass(color) }) - it("updates status when receiving indexingStatusUpdate message", async () => { - renderComponent() - - // Simulate receiving an indexing status update - const event = new MessageEvent("message", { - data: { - type: "indexingStatusUpdate", - values: { - systemStatus: "Indexing", - processedItems: 50, - totalItems: 100, - currentItemUnit: "files", - }, - }, - }) - - act(() => { - window.dispatchEvent(event) - }) - - await waitFor(() => { - const button = screen.getByRole("button") - expect(button).toHaveAttribute("aria-label", "Indexing 50%") - }) + it.each([ + [0, 100, 0], + [25, 100, 25], + [1, 3, 33], + [100, 100, 100], + [0, 0, 0], + ])("keeps progress for %s/%s items", (processedItems, totalItems, percentage) => { + render() + publishStatus({ ...baseStatus, systemStatus: "Indexing", processedItems, totalItems }) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", `Indexing ${percentage}%`) + expect(statusDot()).toHaveClass("bg-yellow-500", "animate-pulse") }) - it("shows error status correctly", async () => { - renderComponent() - - // Simulate error status - const event = new MessageEvent("message", { - data: { - type: "indexingStatusUpdate", - values: { - systemStatus: "Error", - processedItems: 0, - totalItems: 0, - currentItemUnit: "files", - }, - }, - }) - - act(() => { - window.dispatchEvent(event) - }) - - await waitFor(() => { - const button = screen.getByRole("button") - expect(button).toHaveAttribute("aria-label", "Index error") - }) + it.each(["retrying", "exhausted"] as const)( + "indicates %s failures and passes the whole status to the popover", + (state) => { + render() + const status = failureStatus(state) + publishStatus(status) + const prefix = state === "retrying" ? "Indexing 25% - Retrying" : "Index ready - Failed" + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + `${prefix} 1: src/failed.ts: Provider unavailable`, + ) + expect(statusDot()).toHaveClass(state === "retrying" ? "bg-yellow-500" : "bg-amber-500") + expect(lastPopoverStatus()).toEqual(status) + }, + ) + + it.each(["Error", "Stopping"])("does not replace %s with a failure summary", (systemStatus) => { + render() + const status = { ...failureStatus("retrying"), systemStatus, message: "Fatal or stopping message" } + publishStatus(status) + expect(screen.getByRole("button")).toHaveAttribute( + "aria-label", + systemStatus === "Error" ? "Index error" : "Stopping indexing...", + ) + expect(statusDot()).toHaveClass(systemStatus === "Error" ? "bg-red-500" : "bg-amber-500") + expect(lastPopoverStatus()).toEqual(status) }) - it("shows indexed status correctly", async () => { - renderComponent() - - // Simulate indexed status - const event = new MessageEvent("message", { - data: { - type: "indexingStatusUpdate", - values: { - systemStatus: "Indexed", - processedItems: 100, - totalItems: 100, - currentItemUnit: "files", - }, - }, - }) - - act(() => { - window.dispatchEvent(event) - }) - - await waitFor(() => { - const button = screen.getByRole("button") - expect(button).toHaveAttribute("aria-label", "Indexed") - }) + it("indicates list-only failures without inventing a retry state", () => { + render() + publishStatus({ ...failureStatus("exhausted"), failedFileSummary: undefined }) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Index ready - Failed file details (1)") + expect(statusDot()).toHaveClass("bg-amber-500") }) - it("cleans up event listener on unmount", () => { - const { unmount } = renderComponent() - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener") - - unmount() - - expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function)) + it.each([{ failedFiles: undefined }, { failedFiles: [] }])( + "keeps zero-failure compatibility: %s", + ({ failedFiles }) => { + render() + publishStatus({ + ...failureStatus("exhausted"), + systemStatus: "Indexed", + failedFiles, + failedFileSummary: { ...failureStatus("exhausted").failedFileSummary!, count: 0 }, + }) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Indexed") + expect(statusDot()).toHaveClass("bg-green-500") + }, + ) + + it.each(["empty", "omitted"])("forwards waits and clears them when %s in a new snapshot", (snapshot) => { + render() + const waiting = { + ...baseStatus, + systemStatus: "Indexing", + providerWaits: [{ service: "openai", resumeAt: 123_000, waitingFiles: ["waiting.ts"] }], + } + publishStatus(waiting) + expect(lastPopoverStatus()).toEqual(waiting) + expect(statusDot()).toHaveClass("bg-yellow-500") + const recovered = { + ...baseStatus, + systemStatus: "Indexed", + providerWaits: snapshot === "empty" ? [] : undefined, + } + publishStatus(recovered) + expect(lastPopoverStatus()).toEqual(recovered) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Indexed") + expect(statusDot()).toHaveClass("bg-green-500") }) - it("calculates progress percentage correctly", async () => { - renderComponent() + it("preserves Indexed for a search-only pause without file work", () => { + render() + const status = { + ...baseStatus, + systemStatus: "Indexed", + providerWaits: [{ service: "openai", waitingFiles: [] }], + } + publishStatus(status) + expect(lastPopoverStatus()).toEqual(status) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Indexed") + expect(statusDot()).toHaveClass("bg-green-500") + }) - // Test various progress scenarios - const testCases = [ - { processed: 0, total: 100, expected: 0 }, - { processed: 25, total: 100, expected: 25 }, - { processed: 33, total: 100, expected: 33 }, - { processed: 100, total: 100, expected: 100 }, - { processed: 0, total: 0, expected: 0 }, - ] + it("replaces failure fields when recovery arrives", () => { + render() + publishStatus(failureStatus("retrying")) + const recovered = { ...baseStatus, systemStatus: "Indexed", failedFiles: [] } + publishStatus(recovered) + expect(lastPopoverStatus()).toEqual(recovered) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Indexed") + }) - for (const testCase of testCases) { - const event = new MessageEvent("message", { - data: { - type: "indexingStatusUpdate", - values: { - systemStatus: "Indexing", - processedItems: testCase.processed, - totalItems: testCase.total, - currentItemUnit: "files", - }, - }, - }) + it("accepts this workspace and unscoped updates but ignores other workspaces", () => { + render() + const status = failureStatus("exhausted") + publishStatus(status) + publishStatus({ ...baseStatus, systemStatus: "Error", workspacePath: "/other" }) + expect(lastPopoverStatus()).toEqual(status) + publishStatus({ ...baseStatus, systemStatus: "Indexed" }) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Indexed") + }) - act(() => { - window.dispatchEvent(event) - }) + it("ignores unrelated messages", () => { + render() + act(() => window.dispatchEvent(new MessageEvent("message", { data: { type: "workspaceUpdated" } }))) + expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Index ready") + }) - await waitFor(() => { - const button = screen.getByRole("button") - expect(button).toHaveAttribute("aria-label", `Indexing ${testCase.expected}%`) - }) - } + it("cleans up its event listener on unmount", () => { + const addListener = vi.spyOn(window, "addEventListener") + const { unmount } = render() + const registered = addListener.mock.calls.find(([type]) => type === "message")?.[1] + expect(registered).toBeDefined() + const removeListener = vi.spyOn(window, "removeEventListener") + unmount() + expect(removeListener).toHaveBeenCalledWith("message", registered) + addListener.mockRestore() + removeListener.mockRestore() }) }) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-amber-badge.png b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-amber-badge.png new file mode 100644 index 0000000000..dafbc590a8 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-amber-badge.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-failed-files.png b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-failed-files.png new file mode 100644 index 0000000000..5d3ae4da69 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-failed-files.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-provider-wait.png b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-provider-wait.png new file mode 100644 index 0000000000..3202a68a26 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/code-index-status-provider-wait.png differ diff --git a/webview-ui/src/components/chat/__tests__/provider-wait-locales.spec.ts b/webview-ui/src/components/chat/__tests__/provider-wait-locales.spec.ts new file mode 100644 index 0000000000..2a818dfbd2 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/provider-wait-locales.spec.ts @@ -0,0 +1,78 @@ +import { createInstance } from "i18next" + +interface WaitMessages { + providerWaiting: string + providerWaitingUntil: string + providerPaused: string + providerPausedUntil: string +} + +interface SettingsMessages { + codeIndex: WaitMessages +} + +interface EmbeddingMessages { + searchProviderRateLimited: string + orchestrator: Pick +} + +const settingsLocales = import.meta.glob("../../../i18n/locales/*/settings.json", { + eager: true, + import: "default", +}) +const embeddingLocales = import.meta.glob("../../../../../src/i18n/locales/*/embeddings.json", { + eager: true, + import: "default", +}) +const locales = [ + "ca", + "de", + "en", + "es", + "fr", + "hi", + "id", + "it", + "ja", + "ko", + "nl", + "pl", + "pt-BR", + "ru", + "tr", + "vi", + "zh-CN", + "zh-TW", +] + +it.each(locales)("provides matching provider-wait and search keys in %s", async (locale) => { + const settings = settingsLocales[`../../../i18n/locales/${locale}/settings.json`] + const embeddings = embeddingLocales[`../../../../../src/i18n/locales/${locale}/embeddings.json`] + expect(settings).toBeDefined() + expect(embeddings).toBeDefined() + const translation = createInstance() + await translation.init({ + lng: locale, + fallbackLng: false, + resources: { [locale]: { settings, embeddings } }, + interpolation: { escapeValue: false }, + }) + for (const key of ["providerWaiting", "providerWaitingUntil"] as const) { + expect(settings.codeIndex[key]).toBe(embeddings.orchestrator[key]) + } + for (const key of ["providerWaiting", "providerWaitingUntil", "providerPaused", "providerPausedUntil"] as const) { + const message = settings.codeIndex[key] + expect(message).toContain("{{service}}") + expect(message.includes("{{time}}")).toBe(key.endsWith("Until")) + const rendered = translation.t(`settings:codeIndex.${key}`, { service: "test-service", time: "local-time" }) + expect(rendered).toContain("test-service") + expect(rendered).not.toContain("{{") + if (key.endsWith("Until")) expect(rendered).toContain("local-time") + } + expect(embeddings.searchProviderRateLimited).toBeTruthy() + expect(translation.t("embeddings:searchProviderRateLimited")).toBe(embeddings.searchProviderRateLimited) + if (locale !== "en") { + expect(settings.codeIndex.providerWaiting).not.toContain("indexing resumes automatically") + expect(embeddings.searchProviderRateLimited).not.toContain("Search provider rate-limited") + } +}) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 41bbcc4b0f..f7e9c23fcd 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -167,6 +167,10 @@ "description": "Configura les indicacions de suport utilitzades per a accions ràpides com millorar indicacions, explicar codi i solucionar problemes. Aquestes indicacions ajuden Zoo a proporcionar millor assistència per a tasques comunes de desenvolupament." }, "codeIndex": { + "providerWaiting": "Esperant {{service}}; la indexació es reprèn automàticament", + "providerWaitingUntil": "Esperant {{service}}; la indexació es reprèn automàticament a les {{time}}", + "providerPaused": "Esperant {{service}}", + "providerPausedUntil": "Esperant {{service}} fins a les {{time}}", "title": "Indexació de codi", "enableLabel": "Habilitar indexació de codi", "enableDescription": "Habilita la indexació de codi per millorar la cerca i la comprensió del context", @@ -211,6 +215,21 @@ }, "description": "Configureu la configuració d'indexació de la base de codi per habilitar la cerca semàntica del vostre projecte. <0>Més informació", "statusTitle": "Estat", + "indexingFilesRetrying": "Fitxers amb errors d'indexació: {{count}}. S'està reintentant {{path}}: {{reason}}", + "indexingFilesFailed": "Fitxers amb errors d'indexació: {{count}}. El reintent automàtic ha finalitzat per a {{path}}: {{reason}}", + "failedFileDetails": "Detalls dels fitxers amb errors", + "failedFileReason": "Motiu", + "failedFileStates": { + "retrying": "Reintent automàtic pendent", + "exhausted": "Reintent automàtic finalitzat" + }, + "failedFileActivities": { + "read": "Llegir el fitxer", + "parse": "Analitzar el fitxer", + "embed": "Crear embeddings", + "upsert": "Desar els blocs indexats", + "delete": "Eliminar els blocs indexats" + }, "embedderProviderLabel": "Proveïdor d'embeddings", "modelPlaceholder": "Introduïu el nom del model", "selectModel": "Seleccioneu un model", @@ -227,6 +246,7 @@ "saving": "Desant...", "saveSettings": "Desar", "indexingStatuses": { + "stopping": "S'està aturant", "standby": "En espera", "indexing": "Indexant", "indexed": "Indexat", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a4a8b8c606..76a3460d6e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -167,9 +167,28 @@ "description": "Konfiguriere Support-Prompts, die für schnelle Aktionen wie das Verbessern von Prompts, das Erklären von Code und das Beheben von Problemen verwendet werden. Diese Prompts helfen Zoo dabei, bessere Unterstützung für häufige Entwicklungsaufgaben zu bieten." }, "codeIndex": { + "providerWaiting": "Warten auf {{service}}; die Indexierung wird automatisch fortgesetzt", + "providerWaitingUntil": "Warten auf {{service}}; die Indexierung wird um {{time}} automatisch fortgesetzt", + "providerPaused": "Warten auf {{service}}", + "providerPausedUntil": "Warten auf {{service}} bis {{time}}", "title": "Codebase-Indexierung", "description": "Konfiguriere Codebase-Indexierungseinstellungen, um semantische Suche in deinem Projekt zu aktivieren. <0>Mehr erfahren", "statusTitle": "Status", + "indexingFilesRetrying": "Dateien mit Indexierungsfehlern: {{count}}. Erneuter Versuch für {{path}}: {{reason}}", + "indexingFilesFailed": "Dateien mit Indexierungsfehlern: {{count}}. Automatische Wiederholung für {{path}} beendet: {{reason}}", + "failedFileDetails": "Details zu fehlgeschlagenen Dateien", + "failedFileReason": "Grund", + "failedFileStates": { + "retrying": "Automatische Wiederholung ausstehend", + "exhausted": "Automatische Wiederholung beendet" + }, + "failedFileActivities": { + "read": "Datei lesen", + "parse": "Datei parsen", + "embed": "Embeddings erstellen", + "upsert": "Indexierte Blöcke speichern", + "delete": "Indexierte Blöcke entfernen" + }, "enableLabel": "Codebase-Indexierung aktivieren", "enableDescription": "Aktiviere die Code-Indizierung für eine verbesserte Suche und ein besseres Kontextverständnis", "embedderProviderLabel": "Embedder-Anbieter", @@ -227,6 +246,7 @@ "saving": "Speichern...", "saveSettings": "Speichern", "indexingStatuses": { + "stopping": "Wird gestoppt", "standby": "Bereitschaft", "indexing": "Indexierung", "indexed": "Indexiert", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5cd604aed..24947a0907 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -243,6 +243,25 @@ "title": "Codebase Indexing", "description": "Configure codebase indexing settings to enable semantic search of your project. <0>Learn more", "statusTitle": "Status", + "indexingFilesRetrying": "Files with indexing failures: {{count}}. Retrying {{path}}: {{reason}}", + "indexingFilesFailed": "Files with indexing failures: {{count}}. Automatic retry ended for {{path}}: {{reason}}", + "providerWaiting": "Waiting for {{service}}; indexing resumes automatically", + "providerWaitingUntil": "Waiting for {{service}}; indexing resumes automatically at {{time}}", + "providerPaused": "Waiting for {{service}}", + "providerPausedUntil": "Waiting for {{service}} until {{time}}", + "failedFileDetails": "Failed file details", + "failedFileReason": "Reason", + "failedFileStates": { + "retrying": "Automatic retry pending", + "exhausted": "Automatic retry ended" + }, + "failedFileActivities": { + "read": "Read file", + "parse": "Parse file", + "embed": "Create embeddings", + "upsert": "Save indexed blocks", + "delete": "Remove indexed blocks" + }, "enableLabel": "Enable Codebase Indexing", "enableDescription": "Enable code indexing for improved search and context understanding", "embedderProviderLabel": "Embedder Provider", @@ -310,7 +329,8 @@ "standby": "Standby", "indexing": "Indexing", "indexed": "Indexed", - "error": "Error" + "error": "Error", + "stopping": "Stopping" }, "validation": { "qdrantUrlRequired": "Qdrant URL is required", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index a100b767f9..b80332a214 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -167,9 +167,28 @@ "description": "Configura indicaciones de soporte que se utilizan para acciones rápidas como mejorar indicaciones, explicar código y solucionar problemas. Estas indicaciones ayudan a Zoo a brindar mejor asistencia para tareas comunes de desarrollo." }, "codeIndex": { + "providerWaiting": "Esperando a {{service}}; la indexación se reanuda automáticamente", + "providerWaitingUntil": "Esperando a {{service}}; la indexación se reanuda automáticamente a las {{time}}", + "providerPaused": "Esperando a {{service}}", + "providerPausedUntil": "Esperando a {{service}} hasta las {{time}}", "title": "Indexación de código", "description": "Configura los ajustes de indexación de código para habilitar búsqueda semántica en tu proyecto. <0>Más información", "statusTitle": "Estado", + "indexingFilesRetrying": "Archivos con errores de indexación: {{count}}. Reintentando {{path}}: {{reason}}", + "indexingFilesFailed": "Archivos con errores de indexación: {{count}}. El reintento automático terminó para {{path}}: {{reason}}", + "failedFileDetails": "Detalles de archivos con errores", + "failedFileReason": "Motivo", + "failedFileStates": { + "retrying": "Reintento automático pendiente", + "exhausted": "Reintento automático terminado" + }, + "failedFileActivities": { + "read": "Leer archivo", + "parse": "Analizar archivo", + "embed": "Crear embeddings", + "upsert": "Guardar bloques indexados", + "delete": "Eliminar bloques indexados" + }, "enableLabel": "Habilitar indexación de código", "enableDescription": "Habilita la indexación de código para mejorar la búsqueda y la comprensión del contexto", "embedderProviderLabel": "Proveedor de embedder", @@ -227,6 +246,7 @@ "saving": "Guardando...", "saveSettings": "Guardar", "indexingStatuses": { + "stopping": "Deteniendo", "standby": "En espera", "indexing": "Indexando", "indexed": "Indexado", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index aba006610b..96bb80ee0b 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -167,9 +167,28 @@ "description": "Configurez les invites de support utilisées pour les actions rapides comme l'amélioration des invites, l'explication du code et la résolution des problèmes. Ces invites aident Zoo à fournir une meilleure assistance pour les tâches de développement courantes." }, "codeIndex": { + "providerWaiting": "En attente de {{service}} ; l’indexation reprend automatiquement", + "providerWaitingUntil": "En attente de {{service}} ; l’indexation reprend automatiquement à {{time}}", + "providerPaused": "En attente de {{service}}", + "providerPausedUntil": "En attente de {{service}} jusqu’à {{time}}", "title": "Indexation de la base de code", "description": "Configurez les paramètres d'indexation de la base de code pour activer la recherche sémantique dans votre projet. <0>En savoir plus", "statusTitle": "Statut", + "indexingFilesRetrying": "Fichiers en échec d'indexation : {{count}}. Nouvelle tentative pour {{path}} : {{reason}}", + "indexingFilesFailed": "Fichiers en échec d'indexation : {{count}}. Nouvelle tentative automatique terminée pour {{path}} : {{reason}}", + "failedFileDetails": "Détails des fichiers en échec", + "failedFileReason": "Raison", + "failedFileStates": { + "retrying": "Nouvelle tentative automatique en attente", + "exhausted": "Nouvelle tentative automatique terminée" + }, + "failedFileActivities": { + "read": "Lire le fichier", + "parse": "Analyser le fichier", + "embed": "Créer les embeddings", + "upsert": "Enregistrer les blocs indexés", + "delete": "Supprimer les blocs indexés" + }, "enableLabel": "Activer l'indexation de la base de code", "enableDescription": "Activer l'indexation du code pour une recherche et une compréhension du contexte améliorées", "embedderProviderLabel": "Fournisseur d'embedder", @@ -227,6 +246,7 @@ "saving": "Sauvegarde...", "saveSettings": "Sauvegarder", "indexingStatuses": { + "stopping": "Arrêt en cours", "standby": "En attente", "indexing": "Indexation", "indexed": "Indexé", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 274d0b4217..5cb321319b 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -167,6 +167,10 @@ "description": "प्रॉम्प्ट्स को बेहतर बनाना, कोड की व्याख्या करना और समस्याओं को ठीक करना जैसी त्वरित कार्रवाइयों के लिए उपयोग किए जाने वाले सहायक प्रॉम्प्ट्स को कॉन्फ़िगर करें। ये प्रॉम्प्ट्स Zoo को सामान्य विकास कार्यों के लिए बेहतर सहायता प्रदान करने में मदद करते हैं।" }, "codeIndex": { + "providerWaiting": "{{service}} की प्रतीक्षा है; इंडेक्सिंग अपने आप फिर शुरू होगी", + "providerWaitingUntil": "{{service}} की प्रतीक्षा है; इंडेक्सिंग {{time}} पर अपने आप फिर शुरू होगी", + "providerPaused": "{{service}} की प्रतीक्षा है", + "providerPausedUntil": "{{time}} तक {{service}} की प्रतीक्षा है", "title": "कोडबेस इंडेक्सिंग", "enableLabel": "कोडबेस इंडेक्सिंग सक्षम करें", "enableDescription": "बेहतर खोज और संदर्भ समझने के लिए कोड इंडेक्सिंग सक्षम करें", @@ -211,6 +215,21 @@ }, "description": "अपने प्रोजेक्ट की सिमेंटिक खोज को सक्षम करने के लिए कोडबेस इंडेक्सिंग सेटिंग्स कॉन्फ़िगर करें। <0>और जानें", "statusTitle": "स्थिति", + "indexingFilesRetrying": "इंडेक्सिंग में विफल फ़ाइलें: {{count}}। {{path}} के लिए फिर से कोशिश की जा रही है: {{reason}}", + "indexingFilesFailed": "इंडेक्सिंग में विफल फ़ाइलें: {{count}}। {{path}} के लिए स्वचालित पुनः प्रयास समाप्त: {{reason}}", + "failedFileDetails": "विफल फ़ाइलों का विवरण", + "failedFileReason": "कारण", + "failedFileStates": { + "retrying": "स्वचालित पुनः प्रयास लंबित", + "exhausted": "स्वचालित पुनः प्रयास समाप्त" + }, + "failedFileActivities": { + "read": "फ़ाइल पढ़ना", + "parse": "फ़ाइल पार्स करना", + "embed": "एम्बेडिंग बनाना", + "upsert": "इंडेक्स किए गए ब्लॉक सहेजना", + "delete": "इंडेक्स किए गए ब्लॉक हटाना" + }, "embedderProviderLabel": "एम्बेडर प्रदाता", "modelPlaceholder": "मॉडल नाम दर्ज करें", "selectModel": "एक मॉडल चुनें", @@ -227,6 +246,7 @@ "saving": "सहेज रहे हैं...", "saveSettings": "सहेजें", "indexingStatuses": { + "stopping": "रोका जा रहा है", "standby": "स्टैंडबाई", "indexing": "इंडेक्सिंग", "indexed": "इंडेक्स किया गया", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index f88ddf8500..efbbd44cc4 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -167,6 +167,10 @@ "description": "Konfigurasi support prompt yang digunakan untuk aksi cepat seperti meningkatkan prompt, menjelaskan kode, dan memperbaiki masalah. Prompt ini membantu Zoo memberikan bantuan yang lebih baik untuk tugas pengembangan umum." }, "codeIndex": { + "providerWaiting": "Menunggu {{service}}; pengindeksan dilanjutkan secara otomatis", + "providerWaitingUntil": "Menunggu {{service}}; pengindeksan dilanjutkan secara otomatis pada {{time}}", + "providerPaused": "Menunggu {{service}}", + "providerPausedUntil": "Menunggu {{service}} hingga {{time}}", "title": "Pengindeksan Codebase", "enableLabel": "Aktifkan Pengindeksan Codebase", "enableDescription": "Aktifkan pengindeksan kode untuk pencarian dan pemahaman konteks yang lebih baik", @@ -211,6 +215,21 @@ }, "description": "Konfigurasi pengaturan pengindeksan codebase untuk mengaktifkan pencarian semantik proyek kamu. <0>Pelajari lebih lanjut", "statusTitle": "Status", + "indexingFilesRetrying": "File dengan kegagalan pengindeksan: {{count}}. Mencoba ulang {{path}}: {{reason}}", + "indexingFilesFailed": "File dengan kegagalan pengindeksan: {{count}}. Percobaan ulang otomatis berakhir untuk {{path}}: {{reason}}", + "failedFileDetails": "Detail file yang gagal", + "failedFileReason": "Alasan", + "failedFileStates": { + "retrying": "Percobaan ulang otomatis tertunda", + "exhausted": "Percobaan ulang otomatis berakhir" + }, + "failedFileActivities": { + "read": "Baca file", + "parse": "Urai file", + "embed": "Buat embedding", + "upsert": "Simpan blok yang diindeks", + "delete": "Hapus blok yang diindeks" + }, "embedderProviderLabel": "Provider Embedder", "modelPlaceholder": "Masukkan nama model", "selectModel": "Pilih model", @@ -227,6 +246,7 @@ "saving": "Menyimpan...", "saveSettings": "Simpan", "indexingStatuses": { + "stopping": "Menghentikan", "standby": "Siaga", "indexing": "Mengindeks", "indexed": "Terindeks", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index ee1b282786..a46cc8e6eb 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -167,6 +167,10 @@ "description": "Configura i prompt di supporto utilizzati per azioni rapide come il miglioramento dei prompt, la spiegazione del codice e la risoluzione dei problemi. Questi prompt aiutano Zoo a fornire una migliore assistenza per le attività di sviluppo comuni." }, "codeIndex": { + "providerWaiting": "In attesa di {{service}}; l’indicizzazione riprende automaticamente", + "providerWaitingUntil": "In attesa di {{service}}; l’indicizzazione riprende automaticamente alle {{time}}", + "providerPaused": "In attesa di {{service}}", + "providerPausedUntil": "In attesa di {{service}} fino alle {{time}}", "title": "Indicizzazione del codice", "enableLabel": "Abilita indicizzazione del codice", "enableDescription": "Abilita l'indicizzazione del codice per una ricerca e una comprensione del contesto migliorate", @@ -211,6 +215,21 @@ }, "description": "Configura le impostazioni di indicizzazione del codebase per abilitare la ricerca semantica del tuo progetto. <0>Scopri di più", "statusTitle": "Stato", + "indexingFilesRetrying": "File con errori di indicizzazione: {{count}}. Nuovo tentativo per {{path}}: {{reason}}", + "indexingFilesFailed": "File con errori di indicizzazione: {{count}}. Nuovo tentativo automatico terminato per {{path}}: {{reason}}", + "failedFileDetails": "Dettagli dei file con errori", + "failedFileReason": "Motivo", + "failedFileStates": { + "retrying": "Nuovo tentativo automatico in attesa", + "exhausted": "Nuovo tentativo automatico terminato" + }, + "failedFileActivities": { + "read": "Leggere il file", + "parse": "Analizzare il file", + "embed": "Creare gli embedding", + "upsert": "Salvare i blocchi indicizzati", + "delete": "Rimuovere i blocchi indicizzati" + }, "embedderProviderLabel": "Provider Embedder", "modelPlaceholder": "Inserisci il nome del modello", "selectModel": "Seleziona un modello", @@ -227,6 +246,7 @@ "saving": "Salvataggio...", "saveSettings": "Salva", "indexingStatuses": { + "stopping": "Arresto in corso", "standby": "In attesa", "indexing": "Indicizzazione", "indexed": "Indicizzato", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index a277b5485c..a1bd21ce68 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -167,6 +167,10 @@ "description": "プロンプトの強化、コードの説明、問題の修正などの迅速なアクションに使用されるサポートプロンプトを設定します。これらのプロンプトは、Zooが一般的な開発タスクでより良いサポートを提供するのに役立ちます。" }, "codeIndex": { + "providerWaiting": "{{service}} を待機中です。インデックス作成は自動的に再開されます", + "providerWaitingUntil": "{{service}} を待機中です。インデックス作成は {{time}} に自動的に再開されます", + "providerPaused": "{{service}} を待機中です", + "providerPausedUntil": "{{time}} まで {{service}} を待機します", "title": "コードベースのインデックス作成", "enableLabel": "コードベースのインデックス作成を有効化", "enableDescription": "コードのインデックス作成を有効にして、検索とコンテキストの理解を向上させます", @@ -211,6 +215,21 @@ }, "description": "プロジェクトのセマンティック検索を有効にするためのコードベースインデックス設定を構成します。<0>詳細はこちら", "statusTitle": "ステータス", + "indexingFilesRetrying": "インデックス作成に失敗したファイル数:{{count}}。{{path}} を再試行中:{{reason}}", + "indexingFilesFailed": "インデックス作成に失敗したファイル数:{{count}}。{{path}} の自動再試行は終了しました:{{reason}}", + "failedFileDetails": "失敗したファイルの詳細", + "failedFileReason": "理由", + "failedFileStates": { + "retrying": "自動再試行待ち", + "exhausted": "自動再試行終了" + }, + "failedFileActivities": { + "read": "ファイルの読み込み", + "parse": "ファイルの解析", + "embed": "埋め込みの作成", + "upsert": "インデックス化したブロックの保存", + "delete": "インデックス化したブロックの削除" + }, "embedderProviderLabel": "エンベッダープロバイダー", "modelPlaceholder": "モデル名を入力", "selectModel": "モデルを選択", @@ -227,6 +246,7 @@ "saving": "保存中...", "saveSettings": "保存", "indexingStatuses": { + "stopping": "停止中", "standby": "スタンバイ", "indexing": "インデックス中", "indexed": "インデックス済み", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 87bd718210..95b277ffcc 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -167,6 +167,10 @@ "description": "프롬프트 향상, 코드 설명, 문제 해결과 같은 빠른 작업에 사용되는 지원 프롬프트를 구성합니다. 이러한 프롬프트는 Zoo가 일반적인 개발 작업에 대해 더 나은 지원을 제공하는 데 도움이 됩니다." }, "codeIndex": { + "providerWaiting": "{{service}} 대기 중; 인덱싱이 자동으로 재개됩니다", + "providerWaitingUntil": "{{service}} 대기 중; {{time}}에 인덱싱이 자동으로 재개됩니다", + "providerPaused": "{{service}} 대기 중", + "providerPausedUntil": "{{time}}까지 {{service}} 대기 중", "title": "코드베이스 인덱싱", "enableLabel": "코드베이스 인덱싱 활성화", "enableDescription": "향상된 검색 및 컨텍스트 이해를 위해 코드 인덱싱 활성화", @@ -211,6 +215,21 @@ }, "description": "프로젝트의 시맨틱 검색을 활성화하기 위한 코드베이스 인덱싱 설정을 구성합니다. <0>자세히 알아보기", "statusTitle": "상태", + "indexingFilesRetrying": "인덱싱에 실패한 파일: {{count}}. {{path}} 재시도 중: {{reason}}", + "indexingFilesFailed": "인덱싱에 실패한 파일: {{count}}. {{path}} 자동 재시도 종료: {{reason}}", + "failedFileDetails": "실패한 파일 세부 정보", + "failedFileReason": "이유", + "failedFileStates": { + "retrying": "자동 재시도 대기 중", + "exhausted": "자동 재시도 종료" + }, + "failedFileActivities": { + "read": "파일 읽기", + "parse": "파일 파싱", + "embed": "임베딩 생성", + "upsert": "인덱싱된 블록 저장", + "delete": "인덱싱된 블록 제거" + }, "embedderProviderLabel": "임베더 제공자", "modelPlaceholder": "모델 이름을 입력하세요", "selectModel": "모델 선택", @@ -227,6 +246,7 @@ "saving": "저장 중...", "saveSettings": "저장", "indexingStatuses": { + "stopping": "중지 중", "standby": "대기", "indexing": "인덱싱 중", "indexed": "인덱싱됨", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 9185cd1ba8..897d606028 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -167,6 +167,10 @@ "description": "Configureer ondersteuningsprompts die worden gebruikt voor snelle acties zoals het verbeteren van prompts, het uitleggen van code en het oplossen van problemen. Deze prompts helpen Zoo om betere ondersteuning te bieden voor veelvoorkomende ontwikkelingstaken." }, "codeIndex": { + "providerWaiting": "Wachten op {{service}}; de indexering wordt automatisch hervat", + "providerWaitingUntil": "Wachten op {{service}}; de indexering wordt automatisch hervat om {{time}}", + "providerPaused": "Wachten op {{service}}", + "providerPausedUntil": "Wachten op {{service}} tot {{time}}", "title": "Codebase indexering", "enableLabel": "Codebase indexering inschakelen", "enableDescription": "Code-indexering inschakelen voor verbeterde zoekresultaten en contextbegrip", @@ -211,6 +215,21 @@ }, "description": "Configureer codebase-indexeringsinstellingen om semantisch zoeken voor je project in te schakelen. <0>Meer informatie", "statusTitle": "Status", + "indexingFilesRetrying": "Bestanden met indexeringsfouten: {{count}}. Nieuwe poging voor {{path}}: {{reason}}", + "indexingFilesFailed": "Bestanden met indexeringsfouten: {{count}}. Automatische nieuwe poging beëindigd voor {{path}}: {{reason}}", + "failedFileDetails": "Details van mislukte bestanden", + "failedFileReason": "Reden", + "failedFileStates": { + "retrying": "Automatische nieuwe poging in afwachting", + "exhausted": "Automatische nieuwe poging beëindigd" + }, + "failedFileActivities": { + "read": "Bestand lezen", + "parse": "Bestand parseren", + "embed": "Embeddings maken", + "upsert": "Geïndexeerde blokken opslaan", + "delete": "Geïndexeerde blokken verwijderen" + }, "embedderProviderLabel": "Embedder Provider", "modelPlaceholder": "Voer modelnaam in", "selectModel": "Selecteer een model", @@ -227,6 +246,7 @@ "saving": "Opslaan...", "saveSettings": "Opslaan", "indexingStatuses": { + "stopping": "Wordt gestopt", "standby": "Stand-by", "indexing": "Indexeren", "indexed": "Geïndexeerd", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index a3dee217d0..7996814b1e 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -167,6 +167,10 @@ "description": "Skonfiguruj podpowiedzi wsparcia używane do szybkich działań, takich jak ulepszanie podpowiedzi, wyjaśnianie kodu i rozwiązywanie problemów. Te podpowiedzi pomagają Zoo zapewnić lepsze wsparcie dla typowych zadań programistycznych." }, "codeIndex": { + "providerWaiting": "Oczekiwanie na {{service}}; indeksowanie zostanie automatycznie wznowione", + "providerWaitingUntil": "Oczekiwanie na {{service}}; indeksowanie zostanie automatycznie wznowione o {{time}}", + "providerPaused": "Oczekiwanie na {{service}}", + "providerPausedUntil": "Oczekiwanie na {{service}} do {{time}}", "title": "Indeksowanie kodu", "enableLabel": "Włącz indeksowanie kodu", "enableDescription": "Włącz indeksowanie kodu, aby poprawić wyszukiwanie i zrozumienie kontekstu", @@ -211,6 +215,21 @@ }, "description": "Skonfiguruj ustawienia indeksowania bazy kodu, aby włączyć wyszukiwanie semantyczne w swoim projekcie. <0>Dowiedz się więcej", "statusTitle": "Status", + "indexingFilesRetrying": "Pliki z błędami indeksowania: {{count}}. Ponawianie próby dla {{path}}: {{reason}}", + "indexingFilesFailed": "Pliki z błędami indeksowania: {{count}}. Automatyczne ponawianie zakończone dla {{path}}: {{reason}}", + "failedFileDetails": "Szczegóły plików z błędami", + "failedFileReason": "Powód", + "failedFileStates": { + "retrying": "Oczekiwanie na automatyczne ponowienie", + "exhausted": "Automatyczne ponawianie zakończone" + }, + "failedFileActivities": { + "read": "Odczyt pliku", + "parse": "Parsowanie pliku", + "embed": "Tworzenie embeddingów", + "upsert": "Zapisywanie zaindeksowanych bloków", + "delete": "Usuwanie zaindeksowanych bloków" + }, "embedderProviderLabel": "Dostawca Embeddera", "modelPlaceholder": "Wprowadź nazwę modelu", "selectModel": "Wybierz model", @@ -227,6 +246,7 @@ "saving": "Zapisywanie...", "saveSettings": "Zapisz", "indexingStatuses": { + "stopping": "Zatrzymywanie", "standby": "Gotowość", "indexing": "Indeksowanie", "indexed": "Zaindeksowane", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index c5e103eec0..14ac2a8b47 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -167,6 +167,10 @@ "description": "Configure prompts de suporte usados para ações rápidas como melhorar prompts, explicar código e corrigir problemas. Esses prompts ajudam o Zoo a fornecer melhor assistência para tarefas comuns de desenvolvimento." }, "codeIndex": { + "providerWaiting": "Aguardando {{service}}; a indexação é retomada automaticamente", + "providerWaitingUntil": "Aguardando {{service}}; a indexação é retomada automaticamente às {{time}}", + "providerPaused": "Aguardando {{service}}", + "providerPausedUntil": "Aguardando {{service}} até {{time}}", "title": "Indexação de Código", "enableLabel": "Ativar Indexação de Código", "enableDescription": "Ative a indexação de código para pesquisa e compreensão de contexto aprimoradas", @@ -211,6 +215,21 @@ }, "description": "Configure as configurações de indexação da base de código para habilitar a pesquisa semântica do seu projeto. <0>Saiba mais", "statusTitle": "Status", + "indexingFilesRetrying": "Arquivos com falhas de indexação: {{count}}. Tentando novamente {{path}}: {{reason}}", + "indexingFilesFailed": "Arquivos com falhas de indexação: {{count}}. Nova tentativa automática encerrada para {{path}}: {{reason}}", + "failedFileDetails": "Detalhes dos arquivos com falhas", + "failedFileReason": "Motivo", + "failedFileStates": { + "retrying": "Nova tentativa automática pendente", + "exhausted": "Nova tentativa automática encerrada" + }, + "failedFileActivities": { + "read": "Ler arquivo", + "parse": "Analisar arquivo", + "embed": "Criar embeddings", + "upsert": "Salvar blocos indexados", + "delete": "Remover blocos indexados" + }, "embedderProviderLabel": "Provedor de Embedder", "modelPlaceholder": "Insira o nome do modelo", "selectModel": "Selecione um modelo", @@ -227,6 +246,7 @@ "saving": "Salvando...", "saveSettings": "Salvar", "indexingStatuses": { + "stopping": "Parando", "standby": "Em espera", "indexing": "Indexando", "indexed": "Indexado", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 8c944f3797..d33116dd7a 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -167,6 +167,10 @@ "description": "Настройте промпты поддержки, используемые для быстрых действий, таких как улучшение промптов, объяснение кода и исправление проблем. Эти промпты помогают Zoo обеспечить лучшую поддержку для общих задач разработки." }, "codeIndex": { + "providerWaiting": "Ожидание {{service}}; индексация возобновится автоматически", + "providerWaitingUntil": "Ожидание {{service}}; индексация возобновится автоматически в {{time}}", + "providerPaused": "Ожидание {{service}}", + "providerPausedUntil": "Ожидание {{service}} до {{time}}", "title": "Индексация кодовой базы", "enableLabel": "Включить индексацию кодовой базы", "enableDescription": "Включите индексацию кода для улучшения поиска и понимания контекста", @@ -211,6 +215,21 @@ }, "description": "Настройте параметры индексации кодовой базы для включения семантического поиска в вашем проекте. <0>Узнать больше", "statusTitle": "Статус", + "indexingFilesRetrying": "Файлов с ошибками индексации: {{count}}. Повторная попытка для {{path}}: {{reason}}", + "indexingFilesFailed": "Файлов с ошибками индексации: {{count}}. Автоматические повторные попытки для {{path}} завершены: {{reason}}", + "failedFileDetails": "Подробности файлов с ошибками", + "failedFileReason": "Причина", + "failedFileStates": { + "retrying": "Ожидается автоматическая повторная попытка", + "exhausted": "Автоматические повторные попытки завершены" + }, + "failedFileActivities": { + "read": "Чтение файла", + "parse": "Разбор файла", + "embed": "Создание эмбеддингов", + "upsert": "Сохранение проиндексированных блоков", + "delete": "Удаление проиндексированных блоков" + }, "embedderProviderLabel": "Провайдер эмбеддера", "modelPlaceholder": "Введите название модели", "selectModel": "Выберите модель", @@ -227,6 +246,7 @@ "saving": "Сохранение...", "saveSettings": "Сохранить", "indexingStatuses": { + "stopping": "Остановка", "standby": "Ожидание", "indexing": "Индексация", "indexed": "Проиндексировано", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2872f7f9f3..7f29516c10 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -167,6 +167,10 @@ "description": "Prompt geliştirme, kod açıklama ve sorun çözme gibi hızlı eylemler için kullanılan destek promptlarını yapılandırın. Bu promptlar, Zoo'nun yaygın geliştirme görevleri için daha iyi destek sağlamasına yardımcı olur." }, "codeIndex": { + "providerWaiting": "{{service}} bekleniyor; dizinleme otomatik olarak devam eder", + "providerWaitingUntil": "{{service}} bekleniyor; dizinleme {{time}} zamanında otomatik olarak devam eder", + "providerPaused": "{{service}} bekleniyor", + "providerPausedUntil": "{{time}} zamanına kadar {{service}} bekleniyor", "title": "Kod Tabanı İndeksleme", "enableLabel": "Kod Tabanı İndekslemeyi Etkinleştir", "enableDescription": "Geliştirilmiş arama ve bağlam anlayışı için kod indekslemeyi etkinleştirin", @@ -211,6 +215,21 @@ }, "description": "Projenizin anlamsal aramasını etkinleştirmek için kod tabanı indeksleme ayarlarını yapılandırın. <0>Daha fazla bilgi", "statusTitle": "Durum", + "indexingFilesRetrying": "İndeksleme hatası olan dosyalar: {{count}}. {{path}} yeniden deneniyor: {{reason}}", + "indexingFilesFailed": "İndeksleme hatası olan dosyalar: {{count}}. {{path}} için otomatik yeniden deneme sona erdi: {{reason}}", + "failedFileDetails": "Başarısız dosya ayrıntıları", + "failedFileReason": "Neden", + "failedFileStates": { + "retrying": "Otomatik yeniden deneme bekliyor", + "exhausted": "Otomatik yeniden deneme sona erdi" + }, + "failedFileActivities": { + "read": "Dosya okuma", + "parse": "Dosya ayrıştırma", + "embed": "Gömme oluşturma", + "upsert": "İndekslenen blokları kaydetme", + "delete": "İndekslenen blokları kaldırma" + }, "embedderProviderLabel": "Gömücü Sağlayıcı", "modelPlaceholder": "Model adını girin", "selectModel": "Bir model seçin", @@ -227,6 +246,7 @@ "saving": "Kaydediliyor...", "saveSettings": "Kaydet", "indexingStatuses": { + "stopping": "Durduruluyor", "standby": "Bekleme", "indexing": "İndeksleniyor", "indexed": "İndekslendi", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b8c769f584..8c84fc8fbf 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -167,6 +167,10 @@ "description": "Cấu hình các lời nhắc hỗ trợ được sử dụng cho các hành động nhanh như cải thiện lời nhắc, giải thích mã và khắc phục sự cố. Những lời nhắc này giúp Zoo cung cấp hỗ trợ tốt hơn cho các tác vụ phát triển phổ biến." }, "codeIndex": { + "providerWaiting": "Đang chờ {{service}}; việc lập chỉ mục tự động tiếp tục", + "providerWaitingUntil": "Đang chờ {{service}}; việc lập chỉ mục tự động tiếp tục lúc {{time}}", + "providerPaused": "Đang chờ {{service}}", + "providerPausedUntil": "Đang chờ {{service}} đến {{time}}", "title": "Lập chỉ mục mã nguồn", "enableLabel": "Bật lập chỉ mục mã nguồn", "enableDescription": "Bật lập chỉ mục mã để cải thiện tìm kiếm và sự hiểu biết về ngữ cảnh", @@ -211,6 +215,21 @@ }, "description": "Cấu hình cài đặt lập chỉ mục mã nguồn để kích hoạt tìm kiếm ngữ nghĩa cho dự án của bạn. <0>Tìm hiểu thêm", "statusTitle": "Trạng thái", + "indexingFilesRetrying": "Số tệp bị lỗi lập chỉ mục: {{count}}. Đang thử lại {{path}}: {{reason}}", + "indexingFilesFailed": "Số tệp bị lỗi lập chỉ mục: {{count}}. Đã kết thúc thử lại tự động cho {{path}}: {{reason}}", + "failedFileDetails": "Chi tiết tệp bị lỗi", + "failedFileReason": "Lý do", + "failedFileStates": { + "retrying": "Đang chờ thử lại tự động", + "exhausted": "Đã kết thúc thử lại tự động" + }, + "failedFileActivities": { + "read": "Đọc tệp", + "parse": "Phân tích tệp", + "embed": "Tạo embedding", + "upsert": "Lưu các khối đã lập chỉ mục", + "delete": "Xóa các khối đã lập chỉ mục" + }, "embedderProviderLabel": "Nhà cung cấp Embedder", "modelPlaceholder": "Nhập tên mô hình", "selectModel": "Chọn một mô hình", @@ -227,6 +246,7 @@ "saving": "Đang lưu...", "saveSettings": "Lưu", "indexingStatuses": { + "stopping": "Đang dừng", "standby": "Chờ", "indexing": "Đang lập chỉ mục", "indexed": "Đã lập chỉ mục", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 01c6cf7948..7a90bd887d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -167,9 +167,28 @@ "description": "配置用于快速操作的支持提示词,如增强提示词、解释代码和修复问题。这些提示词帮助 Zoo 为常见开发任务提供更好的支持。" }, "codeIndex": { + "providerWaiting": "正在等待 {{service}};索引将自动恢复", + "providerWaitingUntil": "正在等待 {{service}};索引将于 {{time}} 自动恢复", + "providerPaused": "正在等待 {{service}}", + "providerPausedUntil": "正在等待 {{service}},直到 {{time}}", "title": "代码库索引", "description": "配置代码库索引设置以启用项目的语义搜索。<0>了解更多", "statusTitle": "状态", + "indexingFilesRetrying": "索引失败的文件数:{{count}}。正在重试 {{path}}:{{reason}}", + "indexingFilesFailed": "索引失败的文件数:{{count}}。{{path}} 的自动重试已结束:{{reason}}", + "failedFileDetails": "失败文件详情", + "failedFileReason": "原因", + "failedFileStates": { + "retrying": "等待自动重试", + "exhausted": "自动重试已结束" + }, + "failedFileActivities": { + "read": "读取文件", + "parse": "解析文件", + "embed": "创建嵌入向量", + "upsert": "保存已索引的代码块", + "delete": "移除已索引的代码块" + }, "enableLabel": "启用代码库索引", "enableDescription": "启用代码索引以改进搜索和上下文理解", "embedderProviderLabel": "嵌入器提供商", @@ -227,6 +246,7 @@ "saving": "保存中...", "saveSettings": "保存", "indexingStatuses": { + "stopping": "正在停止", "standby": "待机", "indexing": "索引中", "indexed": "已索引", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index ceda034bf6..8fdc9f8f0d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -194,9 +194,28 @@ "description": "設定用於快速操作的支援提示詞,如增強提示詞、解釋程式碼和修復問題。這些提示詞幫助 Zoo 為常見開發工作提供更好的支援。" }, "codeIndex": { + "providerWaiting": "正在等待 {{service}};索引將自動恢復", + "providerWaitingUntil": "正在等待 {{service}};索引將於 {{time}} 自動恢復", + "providerPaused": "正在等待 {{service}}", + "providerPausedUntil": "正在等待 {{service}},直到 {{time}}", "title": "程式碼庫索引", "description": "設定程式碼庫索引設定以啟用專案的語意搜尋。<0>了解更多", "statusTitle": "狀態", + "indexingFilesRetrying": "索引失敗的檔案數:{{count}}。正在重試 {{path}}:{{reason}}", + "indexingFilesFailed": "索引失敗的檔案數:{{count}}。{{path}} 的自動重試已結束:{{reason}}", + "failedFileDetails": "失敗檔案詳細資訊", + "failedFileReason": "原因", + "failedFileStates": { + "retrying": "等待自動重試", + "exhausted": "自動重試已結束" + }, + "failedFileActivities": { + "read": "讀取檔案", + "parse": "剖析檔案", + "embed": "建立嵌入向量", + "upsert": "儲存已索引的區塊", + "delete": "移除已索引的區塊" + }, "enableLabel": "啟用程式碼庫索引", "enableDescription": "啟用程式碼索引以改進搜尋和上下文理解", "embedderProviderLabel": "嵌入器供應商", @@ -260,6 +279,7 @@ "saving": "儲存中...", "saveSettings": "儲存", "indexingStatuses": { + "stopping": "正在停止", "standby": "待命", "indexing": "索引中", "indexed": "已索引",