From 7d71d1015e79b443e6402ebff8c199388ae6a3f8 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 31 Mar 2026 22:48:48 +0800 Subject: [PATCH] fix: harden history recovery and graph persistence regressions --- graph-persistence.js | 47 ++++ index.js | 142 +++++++++--- runtime-state.js | 1 + tests/graph-persistence.mjs | 83 +++++++ tests/p0-regressions.mjs | 429 ++++++++++++++++++++++++++++++++++++ 5 files changed, 669 insertions(+), 33 deletions(-) diff --git a/graph-persistence.js b/graph-persistence.js index 54d72c8..eb676c2 100644 --- a/graph-persistence.js +++ b/graph-persistence.js @@ -242,6 +242,7 @@ export function shouldPreferShadowSnapshotOverOfficial( return { prefer: false, reason: "shadow-missing", + resultCode: "shadow.missing", }; } @@ -260,6 +261,7 @@ export function shouldPreferShadowSnapshotOverOfficial( return { prefer: false, reason: "shadow-revision-invalid", + resultCode: "shadow.reject.revision-invalid", shadowRevision, officialRevision, }; @@ -273,6 +275,7 @@ export function shouldPreferShadowSnapshotOverOfficial( return { prefer: false, reason: "shadow-persisted-chat-mismatch", + resultCode: "shadow.reject.persisted-chat-mismatch", shadowRevision, officialRevision, officialChatId: normalizedOfficialChatId, @@ -288,6 +291,7 @@ export function shouldPreferShadowSnapshotOverOfficial( return { prefer: false, reason: "shadow-chat-mismatch", + resultCode: "shadow.reject.chat-mismatch", shadowRevision, officialRevision, officialChatId: normalizedOfficialChatId, @@ -303,6 +307,7 @@ export function shouldPreferShadowSnapshotOverOfficial( return { prefer: false, reason: "shadow-integrity-mismatch", + resultCode: "shadow.reject.integrity-mismatch", shadowRevision, officialRevision, officialIntegrity, @@ -310,12 +315,54 @@ export function shouldPreferShadowSnapshotOverOfficial( }; } + if ( + normalizedShadowPersistedChatId && + normalizedShadowChatId && + normalizedShadowPersistedChatId !== normalizedShadowChatId + ) { + return { + prefer: false, + reason: "shadow-self-chat-mismatch", + resultCode: "shadow.reject.self-chat-mismatch", + shadowRevision, + officialRevision, + shadowChatId: normalizedShadowChatId, + shadowPersistedChatId: normalizedShadowPersistedChatId, + }; + } + + if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) { + return { + prefer: false, + reason: "shadow-persisted-chat-without-official-chat", + resultCode: "shadow.reject.persisted-chat-without-official-chat", + shadowRevision, + officialRevision, + shadowPersistedChatId: normalizedShadowPersistedChatId, + }; + } + + if (shadowIntegrity && !officialIntegrity) { + return { + prefer: false, + reason: "shadow-integrity-without-official-integrity", + resultCode: "shadow.reject.integrity-without-official-integrity", + shadowRevision, + officialRevision, + shadowIntegrity, + }; + } + return { prefer: shadowRevision > 0 && shadowRevision > officialRevision, reason: shadowRevision > officialRevision ? "shadow-newer-than-official" : "shadow-not-newer-than-official", + resultCode: + shadowRevision > officialRevision + ? "shadow.accept.newer-than-official" + : "shadow.keep.official-not-older", shadowRevision, officialRevision, }; diff --git a/index.js b/index.js index 1751853..99fb1f5 100644 --- a/index.js +++ b/index.js @@ -2790,6 +2790,7 @@ function applyIndexedDbSnapshotToRuntime( normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), normalizedChatId, ); + currentGraph.vectorIndexState.lastIntegrityIssue = null; extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) ? currentGraph.historyState.extractionCount @@ -4373,6 +4374,7 @@ function loadGraphFromChat(options = {}) { source: `${source}:metadata-shadow-compare`, success: Boolean(shadowDecision.prefer), reason: shadowDecision.reason, + resultCode: String(shadowDecision.resultCode || ""), shadowRevision: Number(shadowSnapshot.revision || 0), officialRevision, at: Date.now(), @@ -4441,6 +4443,8 @@ function loadGraphFromChat(options = {}) { success: true, provisional: true, revision: officialRevision, + resultCode: "graph.load.metadata-compat.provisional", + reason: `${source}:metadata-compat-provisional`, at: Date.now(), }, }); @@ -6142,20 +6146,31 @@ function applyRecoveryPlanToVectorState( async function rollbackGraphForReroll(targetFloor, context = getContext()) { ensureCurrentGraphRuntimeState(); const chatId = getCurrentChatId(context); + const buildRerollFailure = ( + recoveryPath, + error, + { resultCode = "reroll.rollback.failed", affectedBatchCount = 0 } = {}, + ) => ({ + success: false, + rollbackPerformed: false, + extractionTriggered: false, + requestedFloor: targetFloor, + effectiveFromFloor: null, + recoveryPath, + affectedBatchCount, + resultCode, + error, + }); const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor); if (!recoveryPoint) { - return { - success: false, - rollbackPerformed: false, - extractionTriggered: false, - requestedFloor: targetFloor, - effectiveFromFloor: null, - recoveryPath: "unavailable", - affectedBatchCount: 0, - error: - "未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。", - }; + return buildRerollFailure( + "unavailable", + "未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。", + { + resultCode: "reroll.rollback.unavailable", + }, + ); } clearInjectionState(); @@ -6171,16 +6186,33 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { targetFloor, ); if (recoveryPlan?.valid === false) { - return { - success: false, - rollbackPerformed: false, - extractionTriggered: false, - requestedFloor: targetFloor, - effectiveFromFloor: null, - recoveryPath: "reverse-journal-rejected", - affectedBatchCount, - error: `回滚计划完整性校验失败: ${recoveryPlan.invalidReason || "unknown"}`, - }; + const invalidReason = String( + recoveryPlan.invalidReason || "unknown", + ).trim(); + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( + "reroll-rollback-rejected", + { + fromFloor: targetFloor, + effectiveFromFloor: null, + path: "reverse-journal", + affectedBatchCount, + detectionSource: "manual-reroll", + reason: `回滚计划完整性校验失败: ${invalidReason}`, + debugReason: `reroll-rollback-plan-invalid:${invalidReason}`, + resultCode: "reroll.rollback.plan-invalid", + invalidReason, + }, + ); + saveGraphToChat({ reason: "reroll-rollback-rejected" }); + refreshPanelLiveState(); + return buildRerollFailure( + "reverse-journal-rejected", + `回滚计划完整性校验失败: ${invalidReason}`, + { + affectedBatchCount, + resultCode: "reroll.rollback.plan-invalid", + }, + ); } rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); @@ -6211,16 +6243,29 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { extractionCount = currentGraph.historyState.extractionCount || 0; await prepareVectorStateForReplay(false); } else { - return { - success: false, - rollbackPerformed: false, - extractionTriggered: false, - requestedFloor: targetFloor, - effectiveFromFloor: null, + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( + "reroll-rollback-rejected", + { + fromFloor: targetFloor, + effectiveFromFloor: null, + path: recoveryPath, + affectedBatchCount, + detectionSource: "manual-reroll", + reason: `不支持的回滚路径: ${recoveryPath}`, + debugReason: `reroll-rollback-unsupported:${recoveryPath}`, + resultCode: "reroll.rollback.path-unsupported", + }, + ); + saveGraphToChat({ reason: "reroll-rollback-rejected" }); + refreshPanelLiveState(); + return buildRerollFailure( recoveryPath, - affectedBatchCount, - error: `不支持的回滚路径: ${recoveryPath}`, - }; + `不支持的回滚路径: ${recoveryPath}`, + { + affectedBatchCount, + resultCode: "reroll.rollback.path-unsupported", + }, + ); } const effectiveFromFloor = Number.isFinite( @@ -6229,9 +6274,6 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { ? currentGraph.historyState.lastProcessedAssistantFloor + 1 : 0; - pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor); - currentGraph.lastProcessedSeq = - currentGraph.historyState?.lastProcessedAssistantFloor ?? -1; clearHistoryDirty( currentGraph, buildRecoveryResult("reroll-rollback", { @@ -6241,8 +6283,13 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { affectedBatchCount, detectionSource: "manual-reroll", reason: "manual-reroll", + resultCode: "reroll.rollback.applied", }), ); + pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor); + currentGraph.lastProcessedSeq = + currentGraph.historyState?.lastProcessedAssistantFloor ?? -1; + currentGraph.vectorIndexState.lastIntegrityIssue = null; saveGraphToChat({ reason: "reroll-rollback-complete" }); refreshPanelLiveState(); @@ -6254,6 +6301,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { effectiveFromFloor, recoveryPath, affectedBatchCount, + resultCode: "reroll.rollback.applied", error: "", }; } @@ -6408,6 +6456,28 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { return true; } catch (error) { if (isAbortError(error)) { + clearHistoryDirty( + currentGraph, + buildRecoveryResult("aborted", { + fromFloor: initialDirtyFrom, + path: recoveryPath, + detectionSource: + detection.source || + currentGraph?.historyState?.lastMutationSource || + "hash-recheck", + affectedBatchCount, + replayedBatchCount: replayedBatches, + reason: error?.message || "已手动终止当前恢复流程", + debugReason: `history-recovery-aborted:${recoveryPath}`, + resultCode: "history.recovery.aborted", + }), + ); + currentGraph.vectorIndexState.lastIntegrityIssue = null; + currentGraph.vectorIndexState.lastWarning = ""; + currentGraph.vectorIndexState.pendingRepairFromFloor = null; + currentGraph.vectorIndexState.replayRequiredNodeIds = []; + currentGraph.vectorIndexState.dirty = false; + currentGraph.vectorIndexState.dirtyReason = ""; updateStageNotice( "history", "历史恢复已终止", @@ -6447,8 +6517,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { affectedBatchCount, replayedBatchCount: replayedBatches, reason: `恢复失败后兜底全量重建: ${error?.message || error}`, + debugReason: `history-recovery-fallback-full-rebuild:${recoveryPath}`, + resultCode: "history.recovery.fallback-full-rebuild", }), ); + currentGraph.vectorIndexState.lastIntegrityIssue = null; saveGraphToChat({ reason: "history-recovery-fallback-rebuild" }); refreshPanelLiveState(); updateStageNotice( @@ -6476,8 +6549,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { affectedBatchCount, replayedBatchCount: replayedBatches, reason: String(fallbackError), + debugReason: `history-recovery-failed:${recoveryPath}`, + resultCode: "history.recovery.failed", }, ); + currentGraph.vectorIndexState.lastIntegrityIssue = null; saveGraphToChat({ reason: "history-recovery-failed" }); refreshPanelLiveState(); updateStageNotice( diff --git a/runtime-state.js b/runtime-state.js index 4b7d207..e017d34 100644 --- a/runtime-state.js +++ b/runtime-state.js @@ -324,6 +324,7 @@ export function clearHistoryDirty(graph, result = null) { graph.historyState.historyDirtyFrom = null; graph.historyState.lastMutationReason = ""; graph.historyState.lastMutationSource = ""; + graph.historyState.processedMessageHashes = {}; if (result) { graph.historyState.lastRecoveryResult = result; } diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 1cde864..e1e596f 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -591,6 +591,18 @@ result = { ); assert.equal(harness.api.getGraphPersistenceState().dbReady, false); assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true); + assert.equal( + harness.api.getGraphPersistenceState().dualWriteLastResult?.resultCode, + "graph.load.metadata-compat.provisional", + ); + assert.equal( + harness.api.getGraphPersistenceState().dualWriteLastResult?.provisional, + true, + ); + assert.equal( + harness.api.getGraphPersistenceState().dualWriteLastResult?.reason, + "global-chat-id:metadata-compat-provisional", + ); } { @@ -1158,6 +1170,77 @@ result = { const live = reader.api.getGraphPersistenceLiveState(); assert.equal(live.shadowSnapshotRevision, 9); assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch"); + const compareDecision = shouldPreferShadowSnapshotOverOfficial( + officialGraph, + reader.api.readGraphShadowSnapshot("chat-shadow-newer"), + ); + assert.equal(compareDecision.resultCode, "shadow.reject.integrity-mismatch"); +} + +{ + const decision = shouldPreferShadowSnapshotOverOfficial( + stampPersistedGraph(createMeaningfulGraph("chat-self-mismatch"), { + revision: 0, + chatId: "", + integrity: "", + }), + { + chatId: "chat-self-mismatch", + persistedChatId: "chat-other", + revision: 5, + integrity: "", + }, + ); + assert.equal(decision.prefer, false); + assert.equal(decision.reason, "shadow-self-chat-mismatch"); + assert.equal(decision.resultCode, "shadow.reject.self-chat-mismatch"); +} + +{ + const decision = shouldPreferShadowSnapshotOverOfficial( + stampPersistedGraph(createMeaningfulGraph("chat-official-missing"), { + revision: 0, + chatId: "", + integrity: "", + }), + { + chatId: "chat-official-missing", + persistedChatId: "chat-official-missing", + revision: 4, + integrity: "", + }, + ); + assert.equal(decision.prefer, false); + assert.equal(decision.reason, "shadow-persisted-chat-without-official-chat"); + assert.equal( + decision.resultCode, + "shadow.reject.persisted-chat-without-official-chat", + ); +} + +{ + const decision = shouldPreferShadowSnapshotOverOfficial( + stampPersistedGraph( + createMeaningfulGraph("chat-official-integrity-missing"), + { + revision: 0, + chatId: "chat-official-integrity-missing", + integrity: "", + }, + ), + { + chatId: "chat-official-integrity-missing", + persistedChatId: "chat-official-integrity-missing", + revision: 4, + integrity: "shadow-only-integrity", + }, + ); + assert.equal(decision.prefer, false); + assert.equal(decision.reason, "shadow-integrity-without-official-integrity"); + assert.equal( + decision.resultCode, + "shadow.reject.integrity-without-official-integrity", + ); } { diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 0faf181..29a6e43 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -368,6 +368,185 @@ function createGenerationRecallHarness() { }); } +function createHistoryRecoveryHarness() { + return fs.readFile(indexPath, "utf8").then((source) => { + const start = source.indexOf("async function recoverHistoryIfNeeded("); + const end = source.indexOf("/**\n * 提取管线:处理未提取的对话楼层"); + if (start < 0 || end < 0 || end <= start) { + throw new Error("无法从 index.js 提取 history recovery 定义"); + } + const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); + const context = { + console, + Date, + result: null, + currentGraph: null, + extractionCount: 0, + isRecoveringHistory: false, + chat: [], + clearedHistoryDirty: null, + prepareVectorStateCalls: [], + saveGraphToChatCalls: 0, + refreshPanelCalls: 0, + notices: [], + embeddingConfig: { mode: "backend" }, + ensureCurrentGraphRuntimeState() { + return context.currentGraph; + }, + beginStageAbortController() { + return { + signal: { aborted: false }, + abort() {}, + }; + }, + finishStageAbortController() {}, + updateStageNotice(...args) { + context.notices.push(args); + }, + inspectHistoryMutation() { + return context.inspectHistoryMutationImpl(); + }, + inspectHistoryMutationImpl() { + return { + dirty: true, + earliestAffectedFloor: 0, + source: "manual-test", + reason: "edited", + }; + }, + getContext() { + return { + chat: context.chat, + chatId: "chat-main", + }; + }, + getCurrentChatId() { + return "chat-main"; + }, + clampRecoveryStartFloor(chat, floor) { + return Math.max(0, Number(floor) || 0); + }, + throwIfAborted(signal, message = "aborted") { + if (signal?.aborted) { + const error = new Error(message); + error.name = "AbortError"; + throw error; + } + }, + createAbortError(message = "aborted") { + const error = new Error(message); + error.name = "AbortError"; + return error; + }, + isAbortError(error) { + return error?.name === "AbortError"; + }, + findJournalRecoveryPoint(graph, floor) { + return context.findJournalRecoveryPointImpl(graph, floor); + }, + findJournalRecoveryPointImpl() { + return null; + }, + buildReverseJournalRecoveryPlan(...args) { + return context.buildReverseJournalRecoveryPlanImpl(...args); + }, + buildReverseJournalRecoveryPlanImpl() { + return { + valid: true, + backendDeleteHashes: [], + replayRequiredNodeIds: [], + pendingRepairFromFloor: 0, + legacyGapFallback: false, + dirtyReason: "history-recovery-replay", + }; + }, + rollbackAffectedJournals() {}, + normalizeGraphRuntimeState(graph) { + return graph; + }, + createEmptyGraph() { + return { + historyState: { + extractionCount: 0, + lastMutationSource: "", + lastMutationReason: "", + }, + vectorIndexState: { + collectionId: "col-1", + dirty: false, + dirtyReason: "", + pendingRepairFromFloor: null, + replayRequiredNodeIds: [], + lastWarning: "", + lastIntegrityIssue: null, + }, + batchJournal: [], + lastProcessedSeq: -1, + }; + }, + getEmbeddingConfig() { + return context.embeddingConfig; + }, + getSettings() { + return {}; + }, + isBackendVectorConfig(config) { + return config?.mode === "backend"; + }, + async deleteBackendVectorHashesForRecovery(...args) { + context.deletedHashesCalls ||= []; + context.deletedHashesCalls.push(args); + }, + async prepareVectorStateForReplay(...args) { + context.prepareVectorStateCalls.push(args); + if (typeof context.prepareVectorStateForReplayImpl === "function") { + return await context.prepareVectorStateForReplayImpl(...args); + } + }, + applyRecoveryPlanToVectorState() {}, + async replayExtractionFromHistory(...args) { + if (typeof context.replayExtractionFromHistoryImpl === "function") { + return await context.replayExtractionFromHistoryImpl(...args); + } + return 0; + }, + clearHistoryDirty(graph, result) { + context.clearedHistoryDirty = result; + graph.historyState ||= {}; + graph.historyState.historyDirtyFrom = null; + graph.historyState.processedMessageHashes = {}; + graph.historyState.lastRecoveryResult = result; + }, + buildRecoveryResult(status, extra = {}) { + return { + status, + ...extra, + }; + }, + saveGraphToChat() { + context.saveGraphToChatCalls += 1; + }, + clearInjectionState() {}, + assertRecoveryChatStillActive() {}, + refreshPanelLiveState() { + context.refreshPanelCalls += 1; + }, + toastr: { + success() {}, + warning() {}, + error() {}, + }, + }; + vm.createContext(context); + vm.runInContext( + `${snippet}\nresult = { recoverFromHistoryMutation: recoverHistoryIfNeeded };`, + context, + { filename: indexPath }, + ); + return context; + }); +} + function createRerollHarness() { return fs.readFile(indexPath, "utf8").then((source) => { const rollbackStart = source.indexOf( @@ -487,6 +666,7 @@ function createRerollHarness() { context.clearedHistoryDirty = result; graph.historyState ||= {}; graph.historyState.historyDirtyFrom = null; + graph.historyState.processedMessageHashes = {}; graph.historyState.lastRecoveryResult = result; }, buildRecoveryResult(status, extra = {}) { @@ -2869,6 +3049,7 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() { assert.equal(result.rollbackPerformed, true); assert.equal(result.recoveryPath, "reverse-journal"); assert.equal(result.effectiveFromFloor, 2); + assert.equal(result.resultCode, "reroll.rollback.applied"); assert.equal(harness.rollbackAffectedJournalsCalls.length, 1); assert.equal(harness.deletedHashesCalls.length, 1); assert.equal(harness.prepareVectorStateCalls.length, 1); @@ -2881,9 +3062,252 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() { harness.currentGraph.historyState.processedMessageHashes[3], undefined, ); + assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.resultCode, + "reroll.rollback.applied", + ); assert.equal(harness.lastExtractedItems.length, 0); } +async function testRerollRejectsInvalidReverseJournalPlanFailClosed() { + const harness = await createRerollHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + { is_user: true, mes: "u2" }, + { is_user: false, mes: "a2" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 3, + processedMessageHashes: { + 1: "hash-1", + 3: "hash-3", + }, + lastRecoveryResult: null, + }, + vectorIndexState: { + collectionId: "col-1", + }, + batchJournal: [{ id: "journal-1" }], + lastProcessedSeq: 3, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "reverse-journal", + affectedBatchCount: 1, + affectedJournals: [{ id: "journal-1" }], + }); + harness.buildReverseJournalRecoveryPlanImpl = () => ({ + valid: false, + invalidReason: "pending-repair-floor-missing", + backendDeleteHashes: [], + replayRequiredNodeIds: [], + }); + + const result = await harness.result.onReroll({ fromFloor: 3 }); + + assert.equal(result.success, false); + assert.equal(result.recoveryPath, "reverse-journal-rejected"); + assert.equal(result.resultCode, "reroll.rollback.plan-invalid"); + assert.equal(harness.rollbackAffectedJournalsCalls.length, 0); + assert.equal(harness.prepareVectorStateCalls.length, 0); + assert.equal(harness.deletedHashesCalls.length, 0); + assert.equal(harness.saveGraphToChatCalls, 1); + assert.equal(harness.refreshPanelCalls, 1); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.status, + "reroll-rollback-rejected", + ); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.resultCode, + "reroll.rollback.plan-invalid", + ); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.debugReason, + "reroll-rollback-plan-invalid:pending-repair-floor-missing", + ); +} + +async function testHistoryRecoveryAbortClearsVectorRepairState() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-edited", + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: { code: "dangling-vector" }, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "full-rebuild", + affectedBatchCount: 0, + }); + harness.prepareVectorStateForReplayImpl = async () => { + throw harness.createAbortError("manual abort"); + }; + + const result = await harness.result.recoverFromHistoryMutation({ + trigger: "message-edited", + dirtyFrom: 1, + detection: { source: "manual-test", reason: "edited" }, + }); + + assert.equal(result, false); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.resultCode, + "history.recovery.aborted", + ); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.debugReason, + "history-recovery-aborted:full-rebuild", + ); + assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null); + assert.equal(harness.currentGraph.vectorIndexState.lastWarning, ""); + assert.equal( + harness.currentGraph.vectorIndexState.pendingRepairFromFloor, + null, + ); + assert.equal( + harness.currentGraph.vectorIndexState.replayRequiredNodeIds.length, + 0, + ); + assert.equal(harness.currentGraph.vectorIndexState.dirty, false); + assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, ""); +} + +async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-edited", + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: { code: "dangling-vector" }, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "legacy-snapshot", + affectedBatchCount: 2, + snapshotBefore: { + historyState: { extractionCount: 0 }, + vectorIndexState: { collectionId: "col-1" }, + batchJournal: [], + lastProcessedSeq: -1, + }, + }); + let replayCallCount = 0; + harness.replayExtractionFromHistoryImpl = async () => { + replayCallCount += 1; + if (replayCallCount === 1) { + throw new Error("replay failed"); + } + return 1; + }; + + const result = await harness.result.recoverFromHistoryMutation({ + trigger: "message-edited", + dirtyFrom: 1, + detection: { source: "manual-test", reason: "edited" }, + }); + + assert.equal(result, true); + assert.equal( + harness.clearedHistoryDirty.resultCode, + "history.recovery.fallback-full-rebuild", + ); + assert.equal( + harness.clearedHistoryDirty.debugReason, + "history-recovery-fallback-full-rebuild:legacy-snapshot", + ); +} + +async function testHistoryRecoveryFailureCarriesResultCode() { + const harness = await createHistoryRecoveryHarness(); + harness.chat = [ + { is_user: true, mes: "u1" }, + { is_user: false, mes: "a1" }, + ]; + harness.currentGraph = { + historyState: { + lastProcessedAssistantFloor: 1, + processedMessageHashes: { 1: "hash-1" }, + historyDirtyFrom: 1, + lastMutationSource: "message-edited", + }, + vectorIndexState: { + collectionId: "col-1", + dirty: true, + dirtyReason: "history-recovery-replay", + pendingRepairFromFloor: 1, + replayRequiredNodeIds: ["node-1"], + lastWarning: "repair pending", + lastIntegrityIssue: { code: "dangling-vector" }, + }, + batchJournal: [], + lastProcessedSeq: 1, + }; + harness.findJournalRecoveryPointImpl = () => ({ + path: "legacy-snapshot", + affectedBatchCount: 1, + snapshotBefore: { + historyState: { extractionCount: 0 }, + vectorIndexState: { collectionId: "col-1" }, + batchJournal: [], + lastProcessedSeq: -1, + }, + }); + harness.replayExtractionFromHistoryImpl = async () => { + throw new Error("replay failed twice"); + }; + + const result = await harness.result.recoverFromHistoryMutation({ + trigger: "message-edited", + dirtyFrom: 1, + detection: { source: "manual-test", reason: "edited" }, + }); + + assert.equal(result, false); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.resultCode, + "history.recovery.failed", + ); + assert.equal( + harness.currentGraph.historyState.lastRecoveryResult.debugReason, + "history-recovery-failed:legacy-snapshot", + ); + assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null); +} async function testRerollRejectsMissingRecoveryPoint() { const harness = await createRerollHarness(); harness.chat = [ @@ -2911,6 +3335,7 @@ async function testRerollRejectsMissingRecoveryPoint() { assert.equal(result.success, false); assert.equal(result.recoveryPath, "unavailable"); + assert.equal(result.resultCode, "reroll.rollback.unavailable"); assert.equal(harness.onManualExtractCalls, 0); assert.equal(harness.saveGraphToChatCalls, 0); } @@ -2943,6 +3368,7 @@ async function testRerollFallsBackToDirectExtractForUnprocessedFloor() { assert.equal(result.rollbackPerformed, false); assert.equal(result.recoveryPath, "direct-extract"); assert.equal(result.effectiveFromFloor, 2); + assert.equal(result.resultCode, undefined); assert.equal(harness.onManualExtractCalls, 1); assert.equal(harness.saveGraphToChatCalls, 0); } @@ -3188,6 +3614,9 @@ await testRecallCardExpandedContentRerendersAfterRecordUpdate(); await testRecallCardUserTextRefreshesWithoutCardRecreate(); await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); +await testHistoryRecoveryAbortClearsVectorRepairState(); +await testHistoryRecoveryFallbackFullRebuildCarriesResultCode(); +await testHistoryRecoveryFailureCarriesResultCode(); await testRerollRejectsMissingRecoveryPoint(); await testRerollFallsBackToDirectExtractForUnprocessedFloor(); await testLlmDebugSnapshotRedactsSecretsBeforeStorage();