From 6e480775324279975e80b38f826bd773d8ce38b1 Mon Sep 17 00:00:00 2001 From: callmeYe <512217680@qq.com> Date: Thu, 9 Jul 2026 09:44:48 +0800 Subject: [PATCH] fix(daemon): surface workspace memory task error details (#6431) * fix(daemon): surface workspace memory task error details * fix(daemon): harden workspace memory error details * fix(daemon): cover workspace memory detail edge cases * fix(daemon): harden workspace memory detail extraction * fix(daemon): harden workspace memory failure diagnostics * fix(daemon): sanitize workspace memory diagnostics * fix(daemon): sanitize workspace memory debug logs * fix(daemon): preserve sanitized memory task stack logs * fix(daemon): harden memory diagnostics redaction * fix(daemon): refine memory task diagnostics * fix(daemon): preserve workspace memory stack diagnostics * fix(daemon): harden workspace memory diagnostics * fix(daemon): guard workspace memory error code extraction * fix(daemon): share workspace memory extraction logging * fix(daemon): suppress workspace memory unavailable details * fix(daemon): clarify workspace memory unavailable timeout logs * fix(daemon): preserve memory diagnostic separators * fix(daemon): harden workspace memory failure handling * fix(daemon): redact split platform tokens * fix(daemon): redact split memory error credentials * fix(daemon): harden workspace memory error diagnostics --- .../cli/src/acp-integration/acpAgent.test.ts | 634 +++++++++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 142 +++- .../serve/workspace-remember-errors.test.ts | 553 ++++++++++++++- .../src/serve/workspace-remember-errors.ts | 321 ++++++++- .../cli/src/serve/workspace-remember.test.ts | 193 +++++- packages/cli/src/serve/workspace-remember.ts | 111 ++- packages/sdk-typescript/src/daemon/types.ts | 3 + 7 files changed, 1877 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 1c455a9f1c..b9339a0bbc 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -54,6 +54,15 @@ const { mockRunManagedAutoMemoryDream, mockRunManagedRememberByAgent } = mockRunManagedRememberByAgent: vi.fn(), })); +const { mockDebugLogger } = vi.hoisted(() => ({ + mockDebugLogger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + vi.mock('@agentclientprotocol/sdk', () => ({ AgentSideConnection: vi.fn().mockImplementation(() => ({ get closed() { @@ -119,12 +128,7 @@ vi.mock('node:stream', async (importOriginal) => { // Mock core dependencies vi.mock('@qwen-code/qwen-code-core', () => ({ - createDebugLogger: () => ({ - debug: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - }), + createDebugLogger: () => mockDebugLogger, registerAcpEventLoopLagGauge: vi.fn(), startEventLoopLagMonitor: vi.fn(() => ({ snapshot: vi.fn(() => ({ @@ -3431,6 +3435,354 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('rejects workspace memory remember when the bridge reports managed memory unavailable', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + let rejection: unknown; + try { + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }); + } catch (err) { + rejection = err; + } + + expect(rejection).toMatchObject({ + code: -32009, + message: 'Managed memory is unavailable for this daemon workspace', + data: { errorKind: 'managed_memory_unavailable' }, + }); + expect( + (rejection as { data: Record }).data, + ).not.toHaveProperty('details'); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember failed:', + expect.objectContaining({ + code: 'managed_memory_unavailable', + details: 'memory service stopped', + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('includes details for workspace memory remember failures', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue( + new Error('remember agent stopped early'), + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }), + ).rejects.toMatchObject({ + code: -32099, + message: 'Workspace memory remember failed', + data: { + errorKind: 'remember_failed', + details: 'remember agent stopped early', + }, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('logs sanitized details for workspace memory failures', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue( + new Error('Authorization: Bearer secret-token-value'), + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }), + ).rejects.toMatchObject({ + code: -32099, + message: 'Workspace memory remember failed', + data: { + errorKind: 'remember_failed', + details: 'Authorization: ', + }, + }); + + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember failed:', + expect.objectContaining({ + code: 'remember_failed', + details: 'Authorization: ', + stack: expect.stringContaining('Authorization: '), + }), + ); + expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain( + 'secret-token-value', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('falls back when workspace memory error code extraction throws', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + const err = new Proxy( + {}, + { + get() { + throw new Error('code getter failed'); + }, + }, + ); + mockRunManagedRememberByAgent.mockRejectedValue(err); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32099, + message: 'Workspace memory remember failed', + data: { errorKind: 'remember_failed' }, + }); + expect( + (error as { data?: Record }).data, + ).not.toHaveProperty('details'); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'Failed to extract workspace memory error code:', + { extractionError: 'code getter failed' }, + ); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember failed:', + expect.objectContaining({ + code: 'remember_failed', + details: '
', + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('uses remember-specific error codes for workspace memory remember timeouts', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue(new Error('late abort')); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + const controller = new AbortController(); + controller.abort(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(controller.signal); + + try { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }), + ).rejects.toMatchObject({ + code: -32099, + message: 'Workspace memory remember timed out', + data: { errorKind: 'remember_timeout', details: 'late abort' }, + }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember timed out:', + expect.objectContaining({ + code: 'remember_timeout', + details: 'late abort', + stack: expect.stringContaining('late abort'), + }), + ); + } finally { + timeoutSpy.mockRestore(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('preserves timeout codes for timed out workspace memory remember unavailable errors', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + const controller = new AbortController(); + controller.abort(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(controller.signal); + + try { + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32099, + message: 'Workspace memory remember timed out', + data: { + errorKind: 'remember_timeout', + details: 'memory service stopped', + }, + }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember timed out:', + expect.objectContaining({ + code: 'remember_timeout', + details: 'memory service stopped', + }), + ); + } finally { + timeoutSpy.mockRestore(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('omits details for workspace memory failures without a detail source', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + }); + mockRunManagedRememberByAgent.mockRejectedValue({ + code: 'remember_failed', + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, { + content: 'Remember me.', + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32099, + data: { errorKind: 'remember_failed' }, + }); + expect( + (error as { data?: Record }).data, + ).not.toHaveProperty('details'); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember failed:', + expect.objectContaining({ details: '
' }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('runs workspace memory forget without requiring a session', async () => { const forget = vi.fn().mockResolvedValue({ systemMessage: 'Forgot 1 entry.', @@ -3556,8 +3908,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).rejects.toMatchObject({ code: -32099, message: 'Workspace memory forget failed', - data: { errorKind: 'forget_failed' }, + data: { errorKind: 'forget_failed', details: 'boom' }, }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory forget failed:', + expect.objectContaining({ + code: 'forget_failed', + details: 'boom', + stack: expect.stringContaining('boom'), + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects workspace memory forget when the bridge reports managed memory unavailable', async () => { + const forget = vi.fn().mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + getMemoryManager: vi.fn().mockReturnValue({ forget }), + getChatRecordingService: vi.fn().mockReturnValue({ + recordUiTelemetryEvent: vi.fn(), + }), + getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, { + query: 'old preference', + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32009, + message: 'Managed memory is unavailable for this daemon workspace', + data: { errorKind: 'managed_memory_unavailable' }, + }); + expect( + (error as { data?: Record }).data, + ).not.toHaveProperty('details'); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory forget failed:', + expect.objectContaining({ + code: 'managed_memory_unavailable', + details: 'memory service stopped', + }), + ); mockConnectionState.resolve(); await agentPromise; @@ -3600,8 +4015,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).rejects.toMatchObject({ code: -32099, message: 'Workspace memory forget timed out', - data: { errorKind: 'forget_timeout' }, + data: { errorKind: 'forget_timeout', details: 'late abort' }, }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory forget timed out:', + expect.objectContaining({ + code: 'forget_timeout', + details: 'late abort', + stack: expect.stringContaining('late abort'), + }), + ); + } finally { + timeoutSpy.mockRestore(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('preserves timeout codes for timed out workspace memory forget unavailable errors', async () => { + const forget = vi.fn().mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + getMemoryManager: vi.fn().mockReturnValue({ forget }), + getChatRecordingService: vi.fn().mockReturnValue({ + recordUiTelemetryEvent: vi.fn(), + }), + getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + const controller = new AbortController(); + controller.abort(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(controller.signal); + + try { + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, { + query: 'old preference', + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32099, + message: 'Workspace memory forget timed out', + data: { + errorKind: 'forget_timeout', + details: 'memory service stopped', + }, + }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory forget timed out:', + expect.objectContaining({ + code: 'forget_timeout', + details: 'memory service stopped', + }), + ); } finally { timeoutSpy.mockRestore(); mockConnectionState.resolve(); @@ -3690,8 +4176,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).rejects.toMatchObject({ code: -32099, message: 'Workspace memory dream failed', - data: { errorKind: 'dream_failed' }, + data: { errorKind: 'dream_failed', details: 'boom' }, }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory dream failed:', + expect.objectContaining({ + code: 'dream_failed', + details: 'boom', + stack: expect.stringContaining('boom'), + }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects workspace memory dream when the bridge reports managed memory unavailable', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + getChatRecordingService: vi.fn().mockReturnValue({ + recordUiTelemetryEvent: vi.fn(), + }), + getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'), + }); + mockRunManagedAutoMemoryDream.mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {}) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32009, + message: 'Managed memory is unavailable for this daemon workspace', + data: { errorKind: 'managed_memory_unavailable' }, + }); + expect( + (error as { data?: Record }).data, + ).not.toHaveProperty('details'); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory dream failed:', + expect.objectContaining({ + code: 'managed_memory_unavailable', + details: 'memory service stopped', + }), + ); mockConnectionState.resolve(); await agentPromise; @@ -3731,8 +4277,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).rejects.toMatchObject({ code: -32099, message: 'Workspace memory dream timed out', - data: { errorKind: 'dream_timeout' }, + data: { errorKind: 'dream_timeout', details: 'late abort' }, }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory dream timed out:', + expect.objectContaining({ + code: 'dream_timeout', + details: 'late abort', + stack: expect.stringContaining('late abort'), + }), + ); + } finally { + timeoutSpy.mockRestore(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('preserves timeout codes for timed out workspace memory dream unavailable errors', async () => { + Object.assign(mockConfig, { + isManagedMemoryAvailable: vi.fn().mockReturnValue(true), + getProjectRoot: vi.fn().mockReturnValue('/workspace'), + getChatRecordingService: vi.fn().mockReturnValue({ + recordUiTelemetryEvent: vi.fn(), + }), + getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'), + }); + mockRunManagedAutoMemoryDream.mockRejectedValue({ + data: { + errorKind: 'managed_memory_unavailable', + details: 'memory service stopped', + }, + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + const controller = new AbortController(); + controller.abort(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(controller.signal); + + try { + const error = await agent + .extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {}) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: -32099, + message: 'Workspace memory dream timed out', + data: { + errorKind: 'dream_timeout', + details: 'memory service stopped', + }, + }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory dream timed out:', + expect.objectContaining({ + code: 'dream_timeout', + details: 'memory service stopped', + }), + ); } finally { timeoutSpy.mockRestore(); mockConnectionState.resolve(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 25b6d34632..90c775a236 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -168,7 +168,12 @@ import { buildDisabledSkillNamesProvider, loadCliConfig, } from '../config/config.js'; -import { extractRememberErrorCode } from '../serve/workspace-remember-errors.js'; +import { + createWorkspaceMemoryExtractionErrorLogger, + shouldSuppressRememberErrorDetails, + workspaceMemoryFailureCode, + workspaceMemoryFailureDiagnostics, +} from '../serve/workspace-remember-errors.js'; import { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js'; import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; @@ -272,6 +277,19 @@ const BTW_CHILD_TIMEOUT_MS = 55_000; // Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts. const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000; +function workspaceMemoryErrorData( + code: string, + diagnostics: { details?: string }, +): { errorKind: string; details?: string } { + return { + errorKind: code, + ...(diagnostics.details ? { details: diagnostics.details } : {}), + }; +} + +const logWorkspaceMemoryExtractionError = + createWorkspaceMemoryExtractionErrorLogger(debugLogger); + function parseAcpLocalReadRootsEnv( raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV], ): string[] { @@ -5812,10 +5830,12 @@ class QwenAgent implements Agent { const childSignal = AbortSignal.timeout( WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS, ); + let projectRoot = ''; try { + projectRoot = this.config.getProjectRoot(); const result = await runManagedRememberByAgent({ config: this.config, - projectRoot: this.config.getProjectRoot(), + projectRoot, content: content.trim(), contextMode, abortSignal: childSignal, @@ -5825,15 +5845,36 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + const code = workspaceMemoryFailureCode( + err, + 'remember_failed', + logWorkspaceMemoryExtractionError, + ); if (childSignal.aborted) { + const timeoutCode = 'remember_timeout'; + debugLogger.error('Workspace memory remember timed out:', { + projectRoot, + code: timeoutCode, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); throw new RequestError( -32099, 'Workspace memory remember timed out', - { errorKind: 'remember_timeout' }, + workspaceMemoryErrorData(timeoutCode, diagnostics), ); } - const code = extractRememberErrorCode(err); - if (code === 'managed_memory_unavailable') { + debugLogger.error('Workspace memory remember failed:', { + projectRoot, + code, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + if (shouldSuppressRememberErrorDetails(code)) { throw new RequestError( -32009, 'Managed memory is unavailable for this daemon workspace', @@ -5842,12 +5883,8 @@ class QwenAgent implements Agent { } throw new RequestError( -32099, - err instanceof Error && err.message - ? err.message - : 'Workspace memory remember failed', - { - errorKind: code, - }, + 'Workspace memory remember failed', + workspaceMemoryErrorData(code, diagnostics), ); } } @@ -5879,8 +5916,9 @@ class QwenAgent implements Agent { const childSignal = AbortSignal.timeout( WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS, ); + let projectRoot = ''; try { - const projectRoot = this.config.getProjectRoot(); + projectRoot = this.config.getProjectRoot(); const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config); const result = await this.config .getMemoryManager() @@ -5900,26 +5938,47 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + const code = workspaceMemoryFailureCode( + err, + 'forget_failed', + logWorkspaceMemoryExtractionError, + ); if (childSignal.aborted) { + const timeoutCode = 'forget_timeout'; + debugLogger.error('Workspace memory forget timed out:', { + projectRoot, + code: timeoutCode, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); throw new RequestError( -32099, 'Workspace memory forget timed out', - { - errorKind: 'forget_timeout', - }, + workspaceMemoryErrorData(timeoutCode, diagnostics), ); } - const code = extractRememberErrorCode(err, 'forget_failed'); - if (code === 'managed_memory_unavailable') { + debugLogger.error('Workspace memory forget failed:', { + projectRoot, + code, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + if (shouldSuppressRememberErrorDetails(code)) { throw new RequestError( -32009, 'Managed memory is unavailable for this daemon workspace', { errorKind: 'managed_memory_unavailable' }, ); } - throw new RequestError(-32099, 'Workspace memory forget failed', { - errorKind: code, - }); + throw new RequestError( + -32099, + 'Workspace memory forget failed', + workspaceMemoryErrorData(code, diagnostics), + ); } } case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: { @@ -5934,9 +5993,11 @@ class QwenAgent implements Agent { const childSignal = AbortSignal.timeout( WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS, ); + let projectRoot = ''; try { + projectRoot = this.config.getProjectRoot(); const result = await runManagedAutoMemoryDream( - this.config.getProjectRoot(), + projectRoot, new Date(), createHiddenWorkspaceMemoryConfig(this.config), childSignal, @@ -5955,22 +6016,47 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + const code = workspaceMemoryFailureCode( + err, + 'dream_failed', + logWorkspaceMemoryExtractionError, + ); if (childSignal.aborted) { - throw new RequestError(-32099, 'Workspace memory dream timed out', { - errorKind: 'dream_timeout', + const timeoutCode = 'dream_timeout'; + debugLogger.error('Workspace memory dream timed out:', { + projectRoot, + code: timeoutCode, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), }); + throw new RequestError( + -32099, + 'Workspace memory dream timed out', + workspaceMemoryErrorData(timeoutCode, diagnostics), + ); } - const code = extractRememberErrorCode(err, 'dream_failed'); - if (code === 'managed_memory_unavailable') { + debugLogger.error('Workspace memory dream failed:', { + projectRoot, + code, + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + if (shouldSuppressRememberErrorDetails(code)) { throw new RequestError( -32009, 'Managed memory is unavailable for this daemon workspace', { errorKind: 'managed_memory_unavailable' }, ); } - throw new RequestError(-32099, 'Workspace memory dream failed', { - errorKind: code, - }); + throw new RequestError( + -32099, + 'Workspace memory dream failed', + workspaceMemoryErrorData(code, diagnostics), + ); } } case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: { diff --git a/packages/cli/src/serve/workspace-remember-errors.test.ts b/packages/cli/src/serve/workspace-remember-errors.test.ts index f1fdef451c..43c0dfca16 100644 --- a/packages/cli/src/serve/workspace-remember-errors.test.ts +++ b/packages/cli/src/serve/workspace-remember-errors.test.ts @@ -5,7 +5,14 @@ */ import { describe, expect, it } from 'vitest'; -import { extractRememberErrorCode } from './workspace-remember-errors.js'; +import { + extractRememberErrorCode, + extractRememberErrorDetails, + extractRememberErrorStack, + shouldSuppressRememberErrorDetails, + workspaceMemoryFailureCode, + workspaceMemoryFailureDiagnostics, +} from './workspace-remember-errors.js'; describe('extractRememberErrorCode', () => { it('extracts remember error codes from common error shapes', () => { @@ -25,9 +32,553 @@ describe('extractRememberErrorCode', () => { cause: { code: 'remember_timeout' }, }), ).toBe('remember_timeout'); + expect( + extractRememberErrorCode({ + cause: { cause: { code: 'remember_path_escape' } }, + }), + ).toBe('remember_path_escape'); expect(extractRememberErrorCode(new Error('boom'))).toBe('remember_failed'); expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe( 'forget_failed', ); }); + + it('limits cause traversal depth', () => { + const root: Record = {}; + let current = root; + for (let index = 0; index < 60; index += 1) { + const next: Record = {}; + current['cause'] = next; + current = next; + } + current['code'] = 'too_deep'; + + expect(extractRememberErrorCode(root)).toBe('remember_failed'); + }); + + it('falls back when code extraction throws', () => { + const extractionErrors: Array<{ target: string; message: string }> = []; + const err = new Proxy( + {}, + { + get() { + throw new Error('code getter failed'); + }, + }, + ); + + expect( + workspaceMemoryFailureCode(err, 'dream_failed', (target, cause) => + extractionErrors.push({ + target, + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ).toBe('dream_failed'); + expect(extractionErrors).toEqual([ + { target: 'code', message: 'code getter failed' }, + ]); + }); + + it('keeps fallback behavior when code extraction logging throws', () => { + const err = new Proxy( + {}, + { + get() { + throw new Error('code getter failed'); + }, + }, + ); + + expect( + workspaceMemoryFailureCode(err, 'dream_failed', () => { + throw new Error('logger failed'); + }), + ).toBe('dream_failed'); + }); +}); + +describe('extractRememberErrorDetails', () => { + it('extracts details from common error shapes', () => { + expect(extractRememberErrorDetails(new Error('boom'))).toBe('boom'); + expect( + extractRememberErrorDetails({ + data: { details: 'agent stopped because max turns exceeded' }, + }), + ).toBe('agent stopped because max turns exceeded'); + expect(extractRememberErrorDetails({ data: 'raw data detail' })).toBe( + 'raw data detail', + ); + expect( + extractRememberErrorDetails({ + data: 'ERR_BRIDGE_INTERNAL', + message: 'Connection to memory service refused', + }), + ).toBe('Connection to memory service refused'); + expect( + extractRememberErrorDetails({ + data: { message: 'provider rejected the request' }, + }), + ).toBe('provider rejected the request'); + expect( + extractRememberErrorDetails({ + cause: new Error('nested failure reason'), + }), + ).toBe('nested failure reason'); + expect(extractRememberErrorDetails({ cause: 'string cause reason' })).toBe( + 'string cause reason', + ); + expect(extractRememberErrorDetails('raw string error')).toBe( + 'raw string error', + ); + }); + + it('prefers bridge details over generic messages', () => { + expect( + extractRememberErrorDetails({ + data: { details: 'specific bridge reason' }, + message: 'generic message', + }), + ).toBe('specific bridge reason'); + }); + + it('redacts credentials before exposing details', () => { + const details = extractRememberErrorDetails( + new Error('Authorization: Bearer secret-token-value'), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + }); + + it('normalizes hidden separators before redacting credentials', () => { + for (const separator of [ + '\u00ad', + '\u061c', + '\u180e', + '\u200b', + '\u2060', + '\u2064', + ]) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer${separator}secret-token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + } + }); + + it('redacts credentials with hidden separators inside token values', () => { + for (const separator of [ + '\u00ad', + '\u061c', + '\u180e', + '\u200b', + '\u2060', + '\u2064', + ]) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer secret${separator}token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret'); + expect(details).not.toContain('token-value'); + } + }); + + it('redacts bearer tokens split by control characters', () => { + const details = extractRememberErrorDetails( + new Error('Authorization: Bearer secret\x01token-value'), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret'); + expect(details).not.toContain('token-value'); + }); + + it('redacts bearer tokens split by unicode space separators', () => { + for (const separator of [ + '\u00a0', + '\u1680', + '\u2000', + '\u2009', + '\u200a', + '\u202f', + '\u205f', + '\u3000', + ]) { + const details = extractRememberErrorDetails( + new Error(`Bearer sk-AAAAAAAAAA${separator}BBBBBBBBBBBBBBB`), + ); + + expect(details).toBe('Bearer '); + expect(details).not.toContain('AAAAAAAAAA'); + expect(details).not.toContain('BBBBBBBBBBBBBBB'); + } + }); + + it('redacts bearer tokens after unicode space separators', () => { + for (const separator of ['\u00a0', '\u2009', '\u202f']) { + const details = extractRememberErrorDetails( + new Error(`Bearer${separator}secret-token-value`), + ); + + expect(details).toBe('Bearer '); + expect(details).not.toContain('secret-token-value'); + } + }); + + it('normalizes unicode space separators without collapsing words', () => { + const details = extractRememberErrorDetails( + new Error('missing column\u00a0name'), + ); + + expect(details).toBe('missing column name'); + }); + + it('redacts bare tokens split by invisible characters', () => { + const details = extractRememberErrorDetails( + new Error('OpenAI key sk-AAAAAAAAAA\u2062BBBBBBBBBBBBBBB'), + ); + + expect(details).toBe('OpenAI key sk-'); + expect(details).not.toContain('AAAAAAAAAA'); + expect(details).not.toContain('BBBBBBBBBBBBBBB'); + }); + + it('redacts platform tokens split by invisible characters', () => { + for (const prefix of [ + 'ghp_', + 'gho_', + 'ghs_', + 'ghu_', + 'github_pat_', + 'glpat-', + 'xoxb-', + 'xoxp-', + ]) { + const details = extractRememberErrorDetails( + new Error(`Platform token ${prefix}AAAAAAAAAA\u200bBBBBBBBBBBBBBBB`), + ); + + expect(details).toBe('Platform token '); + expect(details).not.toContain('AAAAAAAAAA'); + expect(details).not.toContain('BBBBBBBBBBBBBBB'); + } + }); + + it('redacts platform tokens split by unicode space separators', () => { + const details = extractRememberErrorDetails( + new Error('Platform token ghp_AAAAAAAAAA\u00a0BBBBBBBBBBBBBBB'), + ); + + expect(details).toBe('Platform token '); + expect(details).not.toContain('AAAAAAAAAA'); + expect(details).not.toContain('BBBBBBBBBBBBBBB'); + }); + + it('redacts AWS access keys split by credential separators', () => { + for (const separator of ['\x01', '\u00a0', '\u200b']) { + const details = extractRememberErrorDetails( + new Error(`AWS key AKIA12345678${separator}90123456`), + ); + + expect(details).toBe('AWS key '); + expect(details).not.toContain('12345678'); + expect(details).not.toContain('90123456'); + } + }); + + it('redacts secret assignments split by credential separators', () => { + for (const separator of ['\x01', '\u00a0', '\u200b']) { + const details = extractRememberErrorDetails( + new Error(`token=aaaaaaaaaa${separator}bbbbbbbb`), + ); + + expect(details).toBe('token='); + expect(details).not.toContain('aaaaaaaaaa'); + expect(details).not.toContain('bbbbbbbb'); + } + }); + + it('redacts env secret assignments split by credential separators', () => { + const details = extractRememberErrorDetails( + new Error('QWEN_DAEMON_TOKEN=AAAAAAAAAA\u00a0BBBBBBBB'), + ); + + expect(details).toBe('QWEN_DAEMON_TOKEN='); + expect(details).not.toContain('AAAAAAAAAA'); + expect(details).not.toContain('BBBBBBBB'); + }); + + it('redacts URL credentials split by credential separators', () => { + for (const separator of ['\x01', '\u00a0', '\u200b']) { + const details = extractRememberErrorDetails( + new Error( + `postgresql://admin:S3cret${separator}P4ssw0rd@db.internal:5432/mydb`, + ), + ); + + expect(details).toBe('postgresql://@db.internal:5432/mydb'); + expect(details).not.toContain('admin'); + expect(details).not.toContain('S3cret'); + expect(details).not.toContain('P4ssw0rd'); + } + }); + + it('redacts bare bearer tokens separated by invisible characters', () => { + const details = extractRememberErrorDetails( + new Error('Bearer\u200BeyJhbGciOiABCDEFGHIJKLMN'), + ); + + expect(details).toBe('Bearer '); + expect(details).not.toContain('eyJhbGciOiABCDEFGHIJKLMN'); + }); + + it('redacts QQBot tokens separated by invisible characters', () => { + const details = extractRememberErrorDetails( + new Error('QQBot\u200Bsecret-token-value'), + ); + + expect(details).toBe('QQBot '); + expect(details).not.toContain('secret-token-value'); + }); + + it('normalizes line separators before redacting credentials', () => { + for (const separator of ['\u2028', '\u2029']) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer${separator}secret-token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + } + }); + + it('normalizes bidi isolation characters before redacting credentials', () => { + for (const separator of ['\u2066', '\u2067', '\u2068', '\u2069']) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer${separator}secret-token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + } + }); + + it('normalizes BOM before redacting credentials', () => { + const details = extractRememberErrorDetails( + new Error('Authorization: Bearer\ufeffsecret-token-value'), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + }); + + it('sanitizes control characters', () => { + expect(extractRememberErrorDetails(new Error('line1\nline2\ttab'))).toBe( + 'line1 line2 tab', + ); + }); + + it('omits empty details after sanitization', () => { + expect(extractRememberErrorDetails(new Error('\u200b'))).toBeUndefined(); + }); + + it('guards against circular causes', () => { + const cyclic: Record = {}; + cyclic['cause'] = cyclic; + + expect(extractRememberErrorDetails(cyclic)).toBeUndefined(); + }); + + it('limits cause traversal depth', () => { + const root: Record = {}; + let current = root; + for (let index = 0; index < 60; index += 1) { + const next: Record = {}; + current['cause'] = next; + current = next; + } + current['message'] = 'too deep'; + + expect(extractRememberErrorDetails(root)).toBeUndefined(); + }); + + it('caps long details', () => { + const details = extractRememberErrorDetails(new Error('x'.repeat(1100))); + + expect(details).toMatch(/^x+\.{3} \[truncated\]$/); + expect(details).toHaveLength(1000); + }); + + it('does not split surrogate pairs when capping long details', () => { + const details = extractRememberErrorDetails( + new Error(`${'x'.repeat(984)}${'😀'.repeat(100)}`), + ); + + expect(details).toBe(`${'x'.repeat(984)}... [truncated]`); + expect(details).toHaveLength(999); + }); + + it('keeps the full prefix when the cut point falls before a surrogate pair', () => { + const details = extractRememberErrorDetails( + new Error(`${'x'.repeat(985)}${'😀'.repeat(100)}`), + ); + + expect(details).toBe(`${'x'.repeat(985)}... [truncated]`); + expect(details).toHaveLength(1000); + }); +}); + +describe('extractRememberErrorStack', () => { + it('redacts and caps error stacks before logging', () => { + const err = new Error('Authorization: Bearer secret-token-value'); + err.stack = `Error: Authorization: Bearer secret-token-value\n\tat handler (/workspace/file.ts:1:1)\n${'x'.repeat(1100)}`; + + const stack = extractRememberErrorStack(err); + + expect(stack).toContain('Authorization: '); + expect(stack).toContain('\n\tat handler'); + expect(stack).not.toContain('secret-token-value'); + expect(stack).toHaveLength(1000); + }); + + it('preserves CRLF stack line endings before logging', () => { + const err = new Error('boom'); + err.stack = 'Error: boom\r\n\tat handler (/workspace/file.ts:1:1)'; + + const stack = extractRememberErrorStack(err); + + expect(stack).toBe('Error: boom\r\n\tat handler (/workspace/file.ts:1:1)'); + }); + + it('normalizes unicode space separators in stacks without collapsing words', () => { + const err = new Error('boom'); + err.stack = + 'Error: missing column\u202fname\n\tat handler (/workspace/file.ts:1:1)'; + + const stack = extractRememberErrorStack(err); + + expect(stack).toBe( + 'Error: missing column name\n\tat handler (/workspace/file.ts:1:1)', + ); + }); + + it('redacts stack bearer tokens split by control characters', () => { + const err = new Error('Authorization: Bearer secret\x01token-value'); + err.stack = + 'Error: Authorization: Bearer secret\x01token-value\n\tat handler (/workspace/file.ts:1:1)'; + + const stack = extractRememberErrorStack(err); + + expect(stack).toContain('Authorization: '); + expect(stack).not.toContain('secret'); + expect(stack).not.toContain('token-value'); + }); +}); + +describe('workspaceMemoryFailureDiagnostics', () => { + it('falls back when detail extraction throws', () => { + const extractionErrors: Array<{ target: string; message: string }> = []; + const err = new Proxy( + {}, + { + get() { + throw new Error('detail getter failed'); + }, + }, + ); + + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + (target, cause) => + extractionErrors.push({ + target, + message: cause instanceof Error ? cause.message : String(cause), + }), + ); + + expect(diagnostics).toEqual({ debugDetails: '
' }); + expect(extractionErrors).toEqual([ + { target: 'details', message: 'detail getter failed' }, + ]); + }); + + it('keeps fallback diagnostics when detail extraction logging throws', () => { + const err = new Proxy( + {}, + { + get() { + throw new Error('detail getter failed'); + }, + }, + ); + + const diagnostics = workspaceMemoryFailureDiagnostics(err, () => { + throw new Error('logger failed'); + }); + + expect(diagnostics).toEqual({ debugDetails: '
' }); + }); + + it('falls back when stack extraction throws', () => { + const extractionErrors: Array<{ target: string; message: string }> = []; + const err = new Proxy(new Error('boom'), { + get(target, property, receiver) { + if (property === 'stack') { + throw new Error('stack getter failed'); + } + return Reflect.get(target, property, receiver); + }, + }); + + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + (target, cause) => + extractionErrors.push({ + target, + message: cause instanceof Error ? cause.message : String(cause), + }), + ); + + expect(diagnostics).toEqual({ + details: 'boom', + debugDetails: 'boom', + }); + expect(extractionErrors).toEqual([ + { target: 'stack', message: 'stack getter failed' }, + ]); + }); + + it('keeps fallback diagnostics when stack extraction logging throws', () => { + const err = new Proxy(new Error('boom'), { + get(target, property, receiver) { + if (property === 'stack') { + throw new Error('stack getter failed'); + } + return Reflect.get(target, property, receiver); + }, + }); + + const diagnostics = workspaceMemoryFailureDiagnostics(err, () => { + throw new Error('logger failed'); + }); + + expect(diagnostics).toEqual({ + details: 'boom', + debugDetails: 'boom', + }); + }); +}); + +describe('shouldSuppressRememberErrorDetails', () => { + it('suppresses details only for configured public errors', () => { + expect( + shouldSuppressRememberErrorDetails('managed_memory_unavailable'), + ).toBe(true); + expect(shouldSuppressRememberErrorDetails('remember_failed')).toBe(false); + }); }); diff --git a/packages/cli/src/serve/workspace-remember-errors.ts b/packages/cli/src/serve/workspace-remember-errors.ts index 72a2bed1d8..96a8e6b87c 100644 --- a/packages/cli/src/serve/workspace-remember-errors.ts +++ b/packages/cli/src/serve/workspace-remember-errors.ts @@ -4,6 +4,71 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction'; + +const MAX_REMEMBER_ERROR_DETAILS_CHARS = 1000; +const MAX_REMEMBER_ERROR_CAUSE_DEPTH = 50; +const REMEMBER_ERROR_FORMAT_RE = /[\p{Cf}]|\p{Variation_Selector}/gu; +const REMEMBER_ERROR_SPACE_SEPARATOR_RE = + /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu; +/* eslint-disable no-control-regex */ +const REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE = + /[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector}/gu; +const REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE = + /\b(Bearer|QQBot)(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+(?=[A-Za-z0-9._~+/=-])/giu; +const REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE = + /\b(Bearer|QQBot)(?:\s|[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+((?:[A-Za-z0-9._~+/=-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9._~+/=-]+)/giu; +const REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE = + /\b(?:sk-|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[b]-|xox[p]-)(?:[A-Za-z0-9_-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9_-]+/gu; +const REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE = + /\b(?:AKIA|ASIA)(?:[A-Z0-9]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Z0-9]+/gu; +const REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE = + /((?:api[_-]?key|token|secret|password|pwd)[_-]?[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/giu; +const REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE = + /([A-Z][A-Z0-9]{0,50}(?:_[A-Z0-9]{1,50}){0,10}_(?:KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/gu; +const REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE = + /(\b[a-z][a-z0-9+.-]{0,31}:\/\/)([^/@\s]*(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+[^/@\s]*)@/giu; +const REMEMBER_ERROR_CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g; +/* eslint-enable no-control-regex */ +const DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES = new Set([ + 'managed_memory_unavailable', +]); + +export interface WorkspaceMemoryFailureDiagnostics { + details?: string; + debugDetails: string; + stack?: string; +} + +type RememberErrorExtractionTarget = 'code' | 'details' | 'stack'; +type WorkspaceMemoryExtractionLogger = { + warn(message: string, context: { extractionError: string }): void; +}; + +export function createWorkspaceMemoryExtractionErrorLogger( + logger: WorkspaceMemoryExtractionLogger, +): (target: RememberErrorExtractionTarget, err: unknown) => void { + return (target, err) => { + logger.warn(`Failed to extract workspace memory error ${target}:`, { + extractionError: err instanceof Error ? err.message : String(err), + }); + }; +} + +function reportRememberErrorExtractionFailure( + onExtractionError: + | ((target: RememberErrorExtractionTarget, err: unknown) => void) + | undefined, + target: RememberErrorExtractionTarget, + err: unknown, +): void { + try { + onExtractionError?.(target, err); + } catch { + // Preserve fallback behavior if extraction logging fails. + } +} + function errorCodeFromRecord( record: Record, ): string | undefined { @@ -19,19 +84,253 @@ function errorCodeFromRecord( return undefined; } +function rawRememberErrorCode( + err: unknown, + seen: WeakSet, + depth: number, +): string | undefined { + if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined; + if (!err || typeof err !== 'object') return undefined; + if (seen.has(err)) return undefined; + seen.add(err); + + const record = err as Record; + const direct = errorCodeFromRecord(record); + if (direct) return direct; + + const cause = record['cause']; + if (cause != null) { + return rawRememberErrorCode(cause, seen, depth + 1); + } + + return undefined; +} + export function extractRememberErrorCode( err: unknown, fallback = 'remember_failed', ): string { - if (err && typeof err === 'object') { - const record = err as Record; - const direct = errorCodeFromRecord(record); - if (direct) return direct; - const cause = record['cause']; - if (cause && typeof cause === 'object') { - const causedBy = errorCodeFromRecord(cause as Record); - if (causedBy) return causedBy; - } - } - return fallback; + return rawRememberErrorCode(err, new WeakSet(), 0) ?? fallback; +} + +export function workspaceMemoryFailureCode( + err: unknown, + fallback = 'remember_failed', + onExtractionError?: ( + target: RememberErrorExtractionTarget, + err: unknown, + ) => void, +): string { + try { + return extractRememberErrorCode(err, fallback); + } catch (extractionErr) { + reportRememberErrorExtractionFailure( + onExtractionError, + 'code', + extractionErr, + ); + return fallback; + } +} + +function detailFromRecord( + record: Record, + seen: WeakSet, + depth: number, +): string | undefined { + // Bridge errors carry the best failure reason in `data`; top-level + // `message` and `cause` are generic fallbacks. + const data = record['data']; + if (data && typeof data === 'object') { + const dataRecord = data as Record; + const details = dataRecord['details']; + if (typeof details === 'string' && details.length > 0) return details; + const message = dataRecord['message']; + if (typeof message === 'string' && message.length > 0) return message; + } + + const message = record['message']; + if (typeof message === 'string' && message.length > 0) return message; + + if (typeof data === 'string' && data.length > 0) return data; + + const cause = record['cause']; + if (cause != null) { + return rawRememberErrorDetails(cause, seen, depth + 1); + } + + return undefined; +} + +function rawRememberErrorDetails( + err: unknown, + seen: WeakSet, + depth: number, +): string | undefined { + if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined; + if (typeof err === 'string' && err.length > 0) return err; + if (!err || typeof err !== 'object') return undefined; + if (seen.has(err)) return undefined; + seen.add(err); + return detailFromRecord(err as Record, seen, depth); +} + +function replaceControlChars(details: string): string { + return details + .replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ') + .replace(REMEMBER_ERROR_FORMAT_RE, '') + .replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ') + .replace(REMEMBER_ERROR_CONTROL_RE, ' '); +} + +function replaceStackControlChars(stack: string): string { + return stack + .replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ') + .replace(REMEMBER_ERROR_FORMAT_RE, '') + .replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ') + .replace(REMEMBER_ERROR_CONTROL_RE, (char) => + char === '\n' || char === '\r' || char === '\t' ? char : ' ', + ); +} + +function collapseCredentialSeparators(text: string): string { + return text + .replace( + REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE, + (_match, scheme: string, userinfo: string) => + `${scheme}${userinfo.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}@`, + ) + .replace( + REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE, + (_match, scheme: string, token: string) => + `${scheme} ${token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`, + ) + .replace(REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE, (token) => + token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''), + ) + .replace(REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE, (token) => + token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''), + ) + .replace( + REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE, + (_match, prefix: string, value: string) => + `${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`, + ) + .replace( + REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE, + (_match, prefix: string, value: string) => + `${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`, + ); +} + +function collapseCredentialSeparatorsByStackLine(stack: string): string { + return stack + .split(/(\r\n|\n|\r)/) + .map((part) => + part === '\r\n' || part === '\n' || part === '\r' + ? part + : collapseCredentialSeparators(part), + ) + .join(''); +} + +function isHighSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xd800 && codeUnit <= 0xdbff; +} + +function isLowSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; +} + +function truncateBeforeDanglingSurrogate( + details: string, + cutPoint: number, +): number { + const beforeCut = details.charCodeAt(cutPoint - 1); + const atCut = details.charCodeAt(cutPoint); + if (isHighSurrogate(beforeCut) && isLowSurrogate(atCut)) { + return cutPoint - 1; + } + return cutPoint; +} + +function capRememberErrorText(redacted: string): string { + if (redacted.length <= MAX_REMEMBER_ERROR_DETAILS_CHARS) return redacted; + const truncationSuffix = '... [truncated]'; + const cutPoint = truncateBeforeDanglingSurrogate( + redacted, + MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length, + ); + return `${redacted.slice(0, cutPoint)}${truncationSuffix}`; +} + +function redactAndCapRememberErrorText(normalized: string): string | undefined { + const redacted = redactLogCredentials(normalized).trim(); + if (!redacted) return undefined; + return capRememberErrorText(redacted); +} + +function sanitizeRememberErrorDetails(details: string): string | undefined { + // Keep credential normalization before redaction; auth schemes and token + // values may otherwise be split by invisible characters. + return redactAndCapRememberErrorText( + replaceControlChars(collapseCredentialSeparators(details)), + ); +} + +function sanitizeRememberErrorStack(stack: string): string | undefined { + return redactAndCapRememberErrorText( + replaceStackControlChars(collapseCredentialSeparatorsByStackLine(stack)), + ); +} + +export function extractRememberErrorDetails(err: unknown): string | undefined { + const raw = rawRememberErrorDetails(err, new WeakSet(), 0); + if (!raw) return undefined; + return sanitizeRememberErrorDetails(raw); +} + +export function extractRememberErrorStack(err: unknown): string | undefined { + if (!(err instanceof Error) || !err.stack) return undefined; + return sanitizeRememberErrorStack(err.stack); +} + +export function shouldSuppressRememberErrorDetails(code: string): boolean { + return DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES.has(code); +} + +export function workspaceMemoryFailureDiagnostics( + err: unknown, + onExtractionError?: ( + target: RememberErrorExtractionTarget, + err: unknown, + ) => void, +): WorkspaceMemoryFailureDiagnostics { + let details: string | undefined; + let stack: string | undefined; + try { + details = extractRememberErrorDetails(err); + } catch (extractionErr) { + reportRememberErrorExtractionFailure( + onExtractionError, + 'details', + extractionErr, + ); + details = undefined; + } + try { + stack = extractRememberErrorStack(err); + } catch (extractionErr) { + reportRememberErrorExtractionFailure( + onExtractionError, + 'stack', + extractionErr, + ); + stack = undefined; + } + return { + ...(details ? { details } : {}), + debugDetails: details ?? '
', + ...(stack ? { stack } : {}), + }; } diff --git a/packages/cli/src/serve/workspace-remember.test.ts b/packages/cli/src/serve/workspace-remember.test.ts index b0350641ea..641334e240 100644 --- a/packages/cli/src/serve/workspace-remember.test.ts +++ b/packages/cli/src/serve/workspace-remember.test.ts @@ -23,6 +23,19 @@ import { } from './workspace-remember.js'; import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js'; +const { mockDebugLogger } = vi.hoisted(() => ({ + mockDebugLogger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => mockDebugLogger, +})); + type RecordedEvent = Omit; interface Deferred { @@ -211,12 +224,13 @@ function buildApp( tokenConfigured: true, requireAuth: false, }, + lane = new WorkspaceRememberTaskLane(bridge), ) { const app = express(); app.use(express.json({ limit: '1mb' })); mountWorkspaceMemoryRememberRoutes(app, { bridge, - lane: new WorkspaceRememberTaskLane(bridge), + lane, mutate: createMutationGate(auth), parseClientId: (req, res) => { const raw = req.get('x-qwen-client-id'); @@ -863,13 +877,78 @@ describe('workspace memory remember routes', () => { expect(bridge.dreamCalls).toBe(0); }); + it('falls back to kind-specific codes when enqueue code extraction throws', async () => { + mockDebugLogger.warn.mockClear(); + const bridge = buildBridgeStub({}); + const lane = new WorkspaceRememberTaskLane(bridge); + const err = new Proxy( + {}, + { + get() { + throw new Error('code getter failed'); + }, + }, + ); + vi.spyOn(lane, 'enqueue').mockImplementation(() => { + throw err; + }); + vi.spyOn(lane, 'enqueueForget').mockImplementation(() => { + throw err; + }); + vi.spyOn(lane, 'enqueueDream').mockImplementation(() => { + throw err; + }); + const app = buildApp(bridge, undefined, lane); + + await request(app) + .post('/workspace/memory/remember') + .send({ content: 'remember me' }) + .expect(500) + .expect((res) => { + expect(res.body).toEqual({ + error: 'Workspace memory remember failed.', + code: 'remember_failed', + }); + }); + await request(app) + .post('/workspace/memory/forget') + .send({ query: 'old preference' }) + .expect(500) + .expect((res) => { + expect(res.body).toEqual({ + error: 'Workspace memory forget failed.', + code: 'forget_failed', + }); + }); + await request(app) + .post('/workspace/memory/dream') + .send({}) + .expect(500) + .expect((res) => { + expect(res.body).toEqual({ + error: 'Workspace memory dream failed.', + code: 'dream_failed', + }); + }); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'Failed to extract workspace memory error code:', + { extractionError: 'code getter failed' }, + ); + }); + it('records bridge failures with stable public error codes', async () => { const bridge = buildBridgeStub({ rememberImpl: vi .fn() - .mockRejectedValueOnce({ code: 'remember_path_escape' }) + .mockRejectedValueOnce( + Object.assign(new Error('agent wrote /tmp/outside'), { + code: 'remember_path_escape', + }), + ) .mockRejectedValueOnce({ data: { errorKind: 'managed_memory_unavailable' }, + message: 'internal managed memory config path', }) .mockRejectedValueOnce({ data: { errorKind: 'remember_timeout' }, @@ -890,6 +969,7 @@ describe('workspace memory remember routes', () => { expect(res.body.error).toEqual({ code: 'remember_path_escape', message: 'Remember agent touched a path outside managed memory.', + details: 'agent wrote /tmp/outside', }); }); @@ -926,7 +1006,96 @@ describe('workspace memory remember routes', () => { }); }); + it('logs sanitized details for task-lane failures', async () => { + mockDebugLogger.error.mockClear(); + const bridge = buildBridgeStub({ + rememberImpl: vi + .fn() + .mockRejectedValue( + new Error('Authorization: Bearer secret-token-value'), + ), + }); + const app = buildApp(bridge); + + const post = await request(app) + .post('/workspace/memory/remember') + .send({ content: 'secret' }) + .expect(202); + await waitFor(() => bridge.rememberCalls.length === 1); + await request(app) + .get(`/workspace/memory/remember/${post.body.taskId}`) + .expect(200) + .expect((res) => { + expect(res.body.status).toBe('failed'); + expect(res.body.error).toEqual({ + code: 'remember_failed', + message: 'Workspace memory remember failed.', + details: 'Authorization: ', + }); + }); + + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember task failed:', + expect.objectContaining({ + taskId: post.body.taskId, + code: 'remember_failed', + details: 'Authorization: ', + stack: expect.stringContaining('Authorization: '), + }), + ); + expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain( + 'secret-token-value', + ); + }); + + it('falls back when task-lane error code extraction throws', async () => { + mockDebugLogger.error.mockClear(); + mockDebugLogger.warn.mockClear(); + const err = new Proxy( + {}, + { + get() { + throw new Error('code getter failed'); + }, + }, + ); + const bridge = buildBridgeStub({ + rememberImpl: vi.fn().mockRejectedValue(err), + }); + const app = buildApp(bridge); + + const post = await request(app) + .post('/workspace/memory/remember') + .send({ content: 'proxy failure' }) + .expect(202); + await waitFor(() => bridge.rememberCalls.length === 1); + await request(app) + .get(`/workspace/memory/remember/${post.body.taskId}`) + .expect(200) + .expect((res) => { + expect(res.body.status).toBe('failed'); + expect(res.body.error).toEqual({ + code: 'remember_failed', + message: 'Workspace memory remember failed.', + }); + }); + + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'Failed to extract workspace memory error code:', + { extractionError: 'code getter failed' }, + ); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory remember task failed:', + expect.objectContaining({ + taskId: post.body.taskId, + code: 'remember_failed', + details: '
', + }), + ); + }); + it('records forget and dream failures with kind-specific error codes', async () => { + mockDebugLogger.error.mockClear(); const bridge = buildBridgeStub({ forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')), dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')), @@ -946,8 +1115,18 @@ describe('workspace memory remember routes', () => { expect(res.body.error).toEqual({ code: 'forget_failed', message: 'Workspace memory forget failed.', + details: 'forget failed', }); }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory forget task failed:', + expect.objectContaining({ + taskId: forgetPost.body.taskId, + code: 'forget_failed', + details: 'forget failed', + stack: expect.stringContaining('forget failed'), + }), + ); const dreamPost = await request(app) .post('/workspace/memory/dream') @@ -962,7 +1141,17 @@ describe('workspace memory remember routes', () => { expect(res.body.error).toEqual({ code: 'dream_failed', message: 'Workspace memory dream failed.', + details: 'dream failed', }); }); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Workspace memory dream task failed:', + expect.objectContaining({ + taskId: dreamPost.body.taskId, + code: 'dream_failed', + details: 'dream failed', + stack: expect.stringContaining('dream failed'), + }), + ); }); }); diff --git a/packages/cli/src/serve/workspace-remember.ts b/packages/cli/src/serve/workspace-remember.ts index 7f837aa6e9..7ab7422dbc 100644 --- a/packages/cli/src/serve/workspace-remember.ts +++ b/packages/cli/src/serve/workspace-remember.ts @@ -15,7 +15,12 @@ import type { BridgeWorkspaceMemoryRememberContextMode, BridgeWorkspaceMemoryRememberResult, } from './acp-session-bridge.js'; -import { extractRememberErrorCode } from './workspace-remember-errors.js'; +import { + createWorkspaceMemoryExtractionErrorLogger, + shouldSuppressRememberErrorDetails, + workspaceMemoryFailureCode, + workspaceMemoryFailureDiagnostics, +} from './workspace-remember-errors.js'; import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js'; import { formatWorkspaceMemoryDreamSummary, @@ -43,6 +48,7 @@ interface WorkspaceMemoryTaskBaseSnapshot { error?: { code: string; message: string; + details?: string; }; } @@ -179,6 +185,25 @@ export function publicErrorStatus(code: string): number { return 500; } +function createTaskError( + code: string, + kind: WorkspaceMemoryTaskKind, + details?: string, +): WorkspaceMemoryTaskBaseSnapshot['error'] { + const error: WorkspaceMemoryTaskBaseSnapshot['error'] = { + code, + message: publicErrorMessage(code, kind), + }; + if (shouldSuppressRememberErrorDetails(code)) return error; + return { + ...error, + ...(details ? { details } : {}), + }; +} + +const logWorkspaceMemoryExtractionError = + createWorkspaceMemoryExtractionErrorLogger(debugLogger); + export class WorkspaceRememberTaskLane { private static readonly MAX_TASKS = 1000; private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000; @@ -331,17 +356,23 @@ export class WorkspaceRememberTaskLane { }; task.updatedAt = nowIso(); } catch (err) { - const code = extractRememberErrorCode(err); - debugLogger.error( - 'Workspace memory remember task failed:', - { taskId: task.taskId }, + const code = workspaceMemoryFailureCode( err, + 'remember_failed', + logWorkspaceMemoryExtractionError, ); - task.status = 'failed'; - task.error = { + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + debugLogger.error('Workspace memory remember task failed:', { + taskId: task.taskId, code, - message: publicErrorMessage(code, task.kind), - }; + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + task.status = 'failed'; + task.error = createTaskError(code, task.kind, diagnostics.details); task.updatedAt = nowIso(); } try { @@ -397,17 +428,23 @@ export class WorkspaceRememberTaskLane { }; task.updatedAt = nowIso(); } catch (err) { - const code = extractRememberErrorCode(err, 'forget_failed'); - debugLogger.error( - 'Workspace memory forget task failed:', - { taskId: task.taskId }, + const code = workspaceMemoryFailureCode( err, + 'forget_failed', + logWorkspaceMemoryExtractionError, ); - task.status = 'failed'; - task.error = { + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + debugLogger.error('Workspace memory forget task failed:', { + taskId: task.taskId, code, - message: publicErrorMessage(code, task.kind), - }; + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + task.status = 'failed'; + task.error = createTaskError(code, task.kind, diagnostics.details); task.updatedAt = nowIso(); } try { @@ -459,17 +496,23 @@ export class WorkspaceRememberTaskLane { }; task.updatedAt = nowIso(); } catch (err) { - const code = extractRememberErrorCode(err, 'dream_failed'); - debugLogger.error( - 'Workspace memory dream task failed:', - { taskId: task.taskId }, + const code = workspaceMemoryFailureCode( err, + 'dream_failed', + logWorkspaceMemoryExtractionError, ); - task.status = 'failed'; - task.error = { + const diagnostics = workspaceMemoryFailureDiagnostics( + err, + logWorkspaceMemoryExtractionError, + ); + debugLogger.error('Workspace memory dream task failed:', { + taskId: task.taskId, code, - message: publicErrorMessage(code, task.kind), - }; + details: diagnostics.debugDetails, + ...(diagnostics.stack ? { stack: diagnostics.stack } : {}), + }); + task.status = 'failed'; + task.error = createTaskError(code, task.kind, diagnostics.details); task.updatedAt = nowIso(); } try { @@ -610,7 +653,11 @@ export function mountWorkspaceMemoryRememberRoutes( ...(originatorClientId ? { originatorClientId } : {}), }); } catch (err) { - const code = extractRememberErrorCode(err); + const code = workspaceMemoryFailureCode( + err, + 'remember_failed', + logWorkspaceMemoryExtractionError, + ); res.status(publicErrorStatus(code)).json({ error: publicErrorMessage(code, 'remember'), code, @@ -678,7 +725,11 @@ export function mountWorkspaceMemoryRememberRoutes( }); res.status(202).json(task); } catch (err) { - const code = extractRememberErrorCode(err, 'forget_failed'); + const code = workspaceMemoryFailureCode( + err, + 'forget_failed', + logWorkspaceMemoryExtractionError, + ); res.status(publicErrorStatus(code)).json({ error: publicErrorMessage(code, 'forget'), code, @@ -723,7 +774,11 @@ export function mountWorkspaceMemoryRememberRoutes( }); res.status(202).json(task); } catch (err) { - const code = extractRememberErrorCode(err, 'dream_failed'); + const code = workspaceMemoryFailureCode( + err, + 'dream_failed', + logWorkspaceMemoryExtractionError, + ); res.status(publicErrorStatus(code)).json({ error: publicErrorMessage(code, 'dream'), code, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 708d0ebfcd..1a0a82b6fb 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryRememberTask { error?: { code: string; message: string; + details?: string; }; } @@ -1116,6 +1117,7 @@ export interface DaemonWorkspaceMemoryForgetTask { error?: { code: string; message: string; + details?: string; }; } @@ -1138,6 +1140,7 @@ export interface DaemonWorkspaceMemoryDreamTask { error?: { code: string; message: string; + details?: string; }; }