From ebc73d85e2587ef71adb183dc7b3be54309f3020 Mon Sep 17 00:00:00 2001 From: fxl112233 <275098283+fxl112233@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:12:37 +0800 Subject: [PATCH] fix(runtime-host): surface session copy failure cause Preserve rollback semantics while returning a bounded, redacted summary of the commit error and logging the full sanitized diagnostic. Generated-by: OpenAI Codex --- .../session-revision-diagnostics.test.ts | 61 +++++++++++++++ .../server/session-revision-coordinator.ts | 11 ++- .../server/session-revision-diagnostics.ts | 74 +++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/session-revision-diagnostics.test.ts create mode 100644 packages/runtime-host/src/server/session-revision-diagnostics.ts diff --git a/packages/runtime-host/src/__tests__/session-revision-diagnostics.test.ts b/packages/runtime-host/src/__tests__/session-revision-diagnostics.test.ts new file mode 100644 index 0000000000..08915a6b2e --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-revision-diagnostics.test.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + conversationCopyCommitFailureDiagnostic, + conversationCopyCommitFailureMessage, +} from '../server/session-revision-diagnostics.js'; + +describe('Session revision diagnostics', () => { + test('surfaces the error code and redacted message', () => { + const error = Object.assign(new Error('database is locked; apiKey=provider-secret'), { + code: 'SQLITE_BUSY', + }); + + const message = conversationCopyCommitFailureMessage(error); + + assert.equal( + message, + 'Session conversation copy could not be committed: SQLITE_BUSY: database is locked; apiKey=[redacted]', + ); + assert.doesNotMatch(conversationCopyCommitFailureDiagnostic(error), /provider-secret/u); + }); + + test('bounds multibyte error messages to the operation protocol limit', () => { + const message = conversationCopyCommitFailureMessage( + new Error(`archive mismatch ${'归'.repeat(2048)}`), + ); + + assert.ok(Buffer.byteLength(message, 'utf8') <= 1024); + assert.match(message, /…$/u); + }); + + test('does not duplicate a code already present in the error message', () => { + const error = Object.assign(new Error('SQLITE_FULL: database or disk is full'), { + code: 'SQLITE_FULL', + }); + + assert.equal( + conversationCopyCommitFailureMessage(error), + 'Session conversation copy could not be committed: SQLITE_FULL: database or disk is full', + ); + }); +}); diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 4517cd8cf0..9942613fcc 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -72,6 +72,10 @@ import { agentGraphRevisionAdmissionSessionIds, prepareAgentGraphRevisionReferences, } from './session-revision-graph-references.js'; +import { + conversationCopyCommitFailureDiagnostic, + conversationCopyCommitFailureMessage, +} from './session-revision-diagnostics.js'; import { purgeSessionSidecars } from './session-sidecar-purge.js'; type ConversationCopyKind = 'branch' | 'revision'; @@ -562,12 +566,15 @@ export class HostSessionRevisionCoordinator { await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), ), }); - } catch { + } catch (error) { + console.error( + `[runtime-host] ${kind} conversation copy commit failed (${input.sourceSessionId} -> ${input.targetSessionId}): ${conversationCopyCommitFailureDiagnostic(error)}`, + ); return this.#rollbackIncompleteCopy( kind, input, requestFingerprint, - 'Session conversation copy could not be committed', + conversationCopyCommitFailureMessage(error), ); } } diff --git a/packages/runtime-host/src/server/session-revision-diagnostics.ts b/packages/runtime-host/src/server/session-revision-diagnostics.ts new file mode 100644 index 0000000000..b73e297423 --- /dev/null +++ b/packages/runtime-host/src/server/session-revision-diagnostics.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { redactSecrets } from '@maka/core/redaction'; + +const CONVERSATION_COPY_COMMIT_FAILURE = 'Session conversation copy could not be committed'; +const OPERATION_ERROR_MESSAGE_MAX_UTF8_BYTES = 1024; +const HOST_DIAGNOSTIC_MAX_UTF8_BYTES = 8 * 1024; + +export function conversationCopyCommitFailureMessage(error: unknown): string { + const detail = redactSecrets(conversationCopyCommitErrorSummary(error)).trim(); + if (!detail) return CONVERSATION_COPY_COMMIT_FAILURE; + return truncateUtf8( + `${CONVERSATION_COPY_COMMIT_FAILURE}: ${detail}`, + OPERATION_ERROR_MESSAGE_MAX_UTF8_BYTES, + '…', + ); +} + +export function conversationCopyCommitFailureDiagnostic(error: unknown): string { + let detail: string | undefined; + try { + detail = error instanceof Error ? error.stack : undefined; + } catch { + // A hostile Error subclass must not prevent rollback from running. + } + return truncateUtf8( + redactSecrets(detail || conversationCopyCommitErrorSummary(error)), + HOST_DIAGNOSTIC_MAX_UTF8_BYTES, + '\n', + ); +} + +function conversationCopyCommitErrorSummary(error: unknown): string { + try { + const candidate = isRecord(error) ? error : undefined; + const rawCode = candidate?.code; + const code = + typeof rawCode === 'string' || (typeof rawCode === 'number' && Number.isFinite(rawCode)) + ? String(rawCode) + : undefined; + const message = + typeof candidate?.message === 'string' + ? candidate.message + : typeof error === 'string' + ? error + : String(error); + if (!code || message.toLowerCase().includes(code.toLowerCase())) return message; + return message ? `${code}: ${message}` : code; + } catch { + return 'Unknown error'; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +}