diff --git a/package-lock.json b/package-lock.json index 43a6c6b..764f596 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.8.3", + "version": "0.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.8.3", + "version": "0.8.4", "license": "UNLICENSED", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index cfbe114..b4f27c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.8.3", + "version": "0.8.4", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/src/main/knowledge/embedding-errors.test.ts b/src/main/knowledge/embedding-errors.test.ts index d6a7a1c..db332cf 100644 --- a/src/main/knowledge/embedding-errors.test.ts +++ b/src/main/knowledge/embedding-errors.test.ts @@ -31,6 +31,11 @@ describe('embedding error classification', () => { timedOut: true }).code ).toBe('timeout') + const timeout = new Error('Embedding request timed out') + timeout.name = 'TimeoutError' + expect( + classifyEmbeddingError(timeout, { cancelled: true }).code + ).toBe('timeout') }) it('never returns provider bodies, credentials, endpoints or nested causes', () => { diff --git a/src/main/knowledge/embedding-errors.ts b/src/main/knowledge/embedding-errors.ts index f0adf07..5f5c0c0 100644 --- a/src/main/knowledge/embedding-errors.ts +++ b/src/main/knowledge/embedding-errors.ts @@ -105,16 +105,22 @@ function classifyCode( const text = errorText(error) const status = numericStatus(error) ?? statusFromText(text) + if (error instanceof Error && error.name === 'TimeoutError') { + return 'timeout' + } + if (error instanceof Error && error.name === 'AbortError') { + return 'cancelled' + } + if (options.timedOut) { + return 'timeout' + } if ( options.cancelled || hasAny(text, ['aborterror', 'aborted', 'cancelled', 'canceled']) ) { return 'cancelled' } - if ( - options.timedOut || - hasAny(text, ['timeout', 'timed out', 'etimedout']) - ) { + if (hasAny(text, ['timeout', 'timed out', 'etimedout'])) { return 'timeout' } if ( diff --git a/src/main/knowledge/embedding-index-coordinator.test.ts b/src/main/knowledge/embedding-index-coordinator.test.ts index c247945..ed03246 100644 --- a/src/main/knowledge/embedding-index-coordinator.test.ts +++ b/src/main/knowledge/embedding-index-coordinator.test.ts @@ -181,6 +181,89 @@ describe('EmbeddingIndexCoordinator', () => { expect(JSON.stringify(result)).not.toContain('private payload') }) + it('preserves a provider timeout when the diagnostic signal aborts later', async () => { + const repository = new MemoryRepository() + const coordinator = new EmbeddingIndexCoordinator(repository) + const controller = new AbortController() + let rejectProvider: + | ((reason?: unknown) => void) + | undefined + const diagnostic = coordinator.diagnose( + provider( + () => + new Promise((_resolve, reject) => { + rejectProvider = reject + }) + ), + { signal: controller.signal } + ) + await vi.waitFor(() => { + expect(rejectProvider).toBeDefined() + }) + + const timeout = new Error('Embedding request timed out') + timeout.name = 'TimeoutError' + controller.abort() + rejectProvider?.(timeout) + + await expect(diagnostic).resolves.toMatchObject({ + status: 'unavailable', + error: { + code: 'timeout' + } + }) + }) + + it('keeps diagnostics independent from an active rebuild cancellation', async () => { + const repository = new MemoryRepository() + let rebuildSignal: AbortSignal | undefined + let resolveDiagnostic: ((vectors: number[][]) => void) | undefined + const embed = vi.fn( + (input, signal) => { + if (input[0] === 'GoodBuddy 向量模型连接测试') { + return new Promise((resolve) => { + resolveDiagnostic = resolve + }) + } + return new Promise((_resolve, reject) => { + rebuildSignal = signal + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + } + ) + const sharedProvider = provider(embed) + const coordinator = new EmbeddingIndexCoordinator(repository, { + createId: () => 'job-concurrent' + }) + coordinator.startRebuild(sharedProvider) + await vi.waitFor(() => { + expect(rebuildSignal).toBeDefined() + }) + + const diagnostic = coordinator.diagnose(sharedProvider) + await vi.waitFor(() => { + expect(resolveDiagnostic).toBeDefined() + }) + expect(coordinator.cancel('job-concurrent')).toBe(true) + resolveDiagnostic?.([[0.25, 0.5, 0.75]]) + + await expect(diagnostic).resolves.toMatchObject({ + status: 'available', + dimensions: 3 + }) + await expect(coordinator.waitForCompletion()).resolves.toMatchObject({ + status: 'cancelled' + }) + expect(embed.mock.calls).toContainEqual([ + ['GoodBuddy 向量模型连接测试'], + undefined + ]) + }) + it('replaces each document atomically and persists completed progress', async () => { const repository = new MemoryRepository() const coordinator = new EmbeddingIndexCoordinator(repository, { @@ -320,6 +403,42 @@ describe('EmbeddingIndexCoordinator', () => { expect(repository.lastJob).toEqual(cancelled) }) + it('preserves a provider timeout when rebuild cancellation arrives later', async () => { + const repository = new MemoryRepository() + const coordinator = new EmbeddingIndexCoordinator(repository, { + createId: () => 'job-timeout' + }) + let rejectProvider: + | ((reason?: unknown) => void) + | undefined + coordinator.startRebuild( + provider( + () => + new Promise((_resolve, reject) => { + rejectProvider = reject + }) + ) + ) + await vi.waitFor(() => { + expect(rejectProvider).toBeDefined() + }) + + const timeout = new Error('Embedding request timed out') + timeout.name = 'TimeoutError' + expect(coordinator.cancel('job-timeout')).toBe(true) + rejectProvider?.(timeout) + + await expect(coordinator.waitForCompletion()).resolves.toMatchObject({ + status: 'failed', + error: { + code: 'timeout' + } + }) + expect(repository.errors.get('document-1')).toBe( + '向量服务响应超时。' + ) + }) + it('marks an interrupted persisted job cancelled during initialization', async () => { const repository = new MemoryRepository() repository.lastJob = { diff --git a/src/main/knowledge/embedding-index-coordinator.ts b/src/main/knowledge/embedding-index-coordinator.ts index 8e28ed2..bc9047a 100644 --- a/src/main/knowledge/embedding-index-coordinator.ts +++ b/src/main/knowledge/embedding-index-coordinator.ts @@ -431,13 +431,15 @@ export class EmbeddingIndexCoordinator { await this.repository .discardDocumentReplacement(replacementId) .catch(() => undefined) - if (signal.aborted) { - throw error - } const safeError = error instanceof EmbeddingOperationError ? error.toSafeError() - : classifyEmbeddingError(error) + : classifyEmbeddingError(error, { + cancelled: signal.aborted + }) + if (safeError.code === 'cancelled') { + throw error + } await this.repository.recordDocumentError( document.id, provider.provider, @@ -467,9 +469,13 @@ export class EmbeddingIndexCoordinator { } }) } catch (error) { - const cancelled = - signal.aborted || - classifyEmbeddingError(error).code === 'cancelled' + const safeError = + error instanceof EmbeddingOperationError + ? error.toSafeError() + : classifyEmbeddingError(error, { + cancelled: signal.aborted + }) + const cancelled = safeError.code === 'cancelled' this.updateJob( cancelled ? { @@ -479,10 +485,7 @@ export class EmbeddingIndexCoordinator { : { status: 'failed', completedAt: this.now(), - error: - error instanceof EmbeddingOperationError - ? error.toSafeError() - : classifyEmbeddingError(error) + error: safeError } ) } diff --git a/src/main/knowledge/openai-embedding-client.test.ts b/src/main/knowledge/openai-embedding-client.test.ts index 9365ee9..651ca5c 100644 --- a/src/main/knowledge/openai-embedding-client.test.ts +++ b/src/main/knowledge/openai-embedding-client.test.ts @@ -68,6 +68,94 @@ describe('OpenAIEmbeddingClient', () => { ) }) + it('distinguishes its request timeout from caller cancellation', async () => { + const waitForAbort = vi.fn( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ) + }) + ) + const timedClient = new OpenAIEmbeddingClient({ + endpoint: 'http://127.0.0.1:11434/v1/embeddings', + model: 'nomic-embed-text', + timeoutMs: 100, + fetch: waitForAbort + }) + + await expect( + timedClient.embed(['safe synthetic input']) + ).rejects.toMatchObject({ + name: 'TimeoutError', + message: 'Embedding request timed out' + }) + + const caller = new AbortController() + const cancelled = timedClient.embed( + ['safe synthetic input'], + caller.signal + ) + caller.abort(new Error('caller cancelled')) + await expect(cancelled).rejects.toMatchObject({ + name: 'AbortError', + message: 'Embedding request was cancelled' + }) + + let rejectTransport: + | ((reason?: unknown) => void) + | undefined + let transportSignal: AbortSignal | null | undefined + const delayedTransport = vi.fn( + async (_input, init) => + new Promise((_resolve, reject) => { + transportSignal = init?.signal + rejectTransport = reject + }) + ) + const delayedClient = new OpenAIEmbeddingClient({ + endpoint: 'http://127.0.0.1:11434/v1/embeddings', + model: 'nomic-embed-text', + timeoutMs: 100, + fetch: delayedTransport + }) + const lateCaller = new AbortController() + const timeoutThenCancellation = delayedClient.embed( + ['safe synthetic input'], + lateCaller.signal + ) + await vi.waitFor( + () => { + expect(transportSignal?.aborted).toBe(true) + expect(transportSignal?.reason).toMatchObject({ + name: 'TimeoutError' + }) + }, + { interval: 5, timeout: 500 } + ) + lateCaller.abort() + rejectTransport?.(transportSignal?.reason) + await expect(timeoutThenCancellation).rejects.toMatchObject({ + name: 'TimeoutError', + message: 'Embedding request timed out' + }) + + const preCancelled = new AbortController() + preCancelled.abort(new Error('caller cancelled before request')) + await expect( + delayedClient.embed( + ['safe synthetic input'], + preCancelled.signal + ) + ).rejects.toMatchObject({ + name: 'AbortError', + message: 'Embedding request was cancelled' + }) + expect(delayedTransport).toHaveBeenCalledTimes(1) + }) + it('rejects unsafe endpoints and malformed vectors', async () => { expect( () => diff --git a/src/main/knowledge/openai-embedding-client.ts b/src/main/knowledge/openai-embedding-client.ts index 3147a2d..2c13393 100644 --- a/src/main/knowledge/openai-embedding-client.ts +++ b/src/main/knowledge/openai-embedding-client.ts @@ -9,7 +9,8 @@ const MAX_URL_LENGTH = 2_048 const MAX_DIMENSIONS = 8_192 const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 const MIN_TIMEOUT_MS = 100 -const MAX_TIMEOUT_MS = 120_000 +const DEFAULT_TIMEOUT_MS = 120_000 +const MAX_TIMEOUT_MS = DEFAULT_TIMEOUT_MS export interface OpenAIEmbeddingClientOptions { endpoint: string @@ -64,6 +65,18 @@ function normalizedEndpoint(input: string): string { return url.toString() } +function embeddingAbortError( + requestSignal: AbortSignal, + timeoutError: Error +): Error { + if (requestSignal.reason === timeoutError) { + return timeoutError + } + const error = new Error('Embedding request was cancelled') + error.name = 'AbortError' + return error +} + async function readBoundedJson(response: Response): Promise { const declaredLength = response.headers.get('content-length') if ( @@ -187,7 +200,7 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { MAX_BATCH_SIZE ) this.timeoutMs = boundedInteger( - options.timeoutMs ?? 15_000, + options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 'timeoutMs', MIN_TIMEOUT_MS, MAX_TIMEOUT_MS @@ -255,11 +268,19 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { input: readonly string[], signal?: AbortSignal ): Promise { - if (signal?.aborted) { - throw signal.reason + const timeoutError = new Error('Embedding request timed out') + timeoutError.name = 'TimeoutError' + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => { + timeoutController.abort(timeoutError) + }, this.timeoutMs) + const requestSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal + if (requestSignal.aborted) { + clearTimeout(timeoutId) + throw embeddingAbortError(requestSignal, timeoutError) } - const timeout = AbortSignal.timeout(this.timeoutMs) - const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout const headers: Record = { accept: 'application/json', 'content-type': 'application/json' @@ -267,7 +288,7 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { if (this.apiKey) { headers.authorization = `Bearer ${this.apiKey}` } - let response: Response + let response: Response | undefined try { response = await this.transport(this.endpoint, { method: 'POST', @@ -276,17 +297,25 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider { redirect: 'error', signal: requestSignal }) + if (!response.ok) { + throw new Error( + `Embedding request failed with HTTP ${response.status}` + ) + } + return validateEmbeddings( + await readBoundedJson(response), + input.length + ) } catch (error) { if (requestSignal.aborted) { - const abortError = new Error('Embedding request was cancelled') - abortError.name = 'AbortError' - throw abortError + throw embeddingAbortError(requestSignal, timeoutError) + } + if (response) { + throw error } throw new Error('Embedding request failed', { cause: error }) + } finally { + clearTimeout(timeoutId) } - if (!response.ok) { - throw new Error(`Embedding request failed with HTTP ${response.status}`) - } - return validateEmbeddings(await readBoundedJson(response), input.length) } } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 382d194..f4c3529 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -552,7 +552,7 @@ describe('App', () => { it('checks for updates silently on startup and only reports a new version', async () => { const check = vi.fn(async () => ({ updateAvailable: true, - currentVersion: '0.8.3', + currentVersion: '0.8.4', latestVersion: '0.9.0', releaseUrl: 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0', @@ -595,10 +595,10 @@ describe('App', () => { it('does not disturb startup when updates are current or offline', async () => { const currentResult = { updateAvailable: false, - currentVersion: '0.8.3', - latestVersion: '0.8.3', + currentVersion: '0.8.4', + latestVersion: '0.8.4', releaseUrl: - 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.3', + 'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.4', target: { platform: 'windows' as const, arch: 'x64' as const, diff --git a/src/shared/embedding-contracts.test.ts b/src/shared/embedding-contracts.test.ts index ee4dac9..5e716e3 100644 --- a/src/shared/embedding-contracts.test.ts +++ b/src/shared/embedding-contracts.test.ts @@ -23,6 +23,16 @@ describe('embedding contracts', () => { latencyMs: 184, dimensions: 1_536 }) + expect( + embeddingDiagnosticResultSchema.safeParse({ + status: 'available', + provider: 'provider', + model: 'slow-local-model', + checkedAt: 1_700_000_000_000, + latencyMs: 120_001, + dimensions: 4_096 + }).success + ).toBe(true) expect( embeddingDiagnosticResultSchema.safeParse({ diff --git a/src/shared/embedding-contracts.ts b/src/shared/embedding-contracts.ts index 6568a0e..6a078aa 100644 --- a/src/shared/embedding-contracts.ts +++ b/src/shared/embedding-contracts.ts @@ -56,7 +56,7 @@ const embeddingDiagnosticBase = { provider: boundedLabelSchema, model: boundedLabelSchema, checkedAt: timestampSchema, - latencyMs: countSchema.max(120_000) + latencyMs: countSchema } export const embeddingDiagnosticResultSchema = z.discriminatedUnion('status', [