From ed35b1d8efc3d39d2282947d7e0a85cadfaabb94 Mon Sep 17 00:00:00 2001 From: youzini Date: Sun, 31 May 2026 11:27:16 +0000 Subject: [PATCH] refactor(maintenance): extract history-recovery controller, fully migrate p0 off index.js slicing --- index.js | 410 +++--------------- maintenance/reroll-recovery-controller.js | 421 +++++++++++++++++++ tests/index-slicing-ratchet.mjs | 1 - tests/p0-regressions.mjs | 491 ++++++++++++---------- 4 files changed, 746 insertions(+), 577 deletions(-) diff --git a/index.js b/index.js index 8718b7c..af1f091 100644 --- a/index.js +++ b/index.js @@ -121,7 +121,10 @@ import { resolveCurrentBmeChatStateTarget, serializeBmeChatStateTarget, } from "./host/runtime-host-adapter.js"; -import { rollbackGraphForRerollController } from "./maintenance/reroll-recovery-controller.js"; +import { + recoverHistoryIfNeededController, + rollbackGraphForRerollController, +} from "./maintenance/reroll-recovery-controller.js"; import { handleExtractionSuccessController, shouldAdvanceProcessedHistory as shouldAdvanceProcessedHistoryController, @@ -20682,368 +20685,57 @@ async function tryDeleteBackendVectorHashesForRecovery( } async function recoverHistoryIfNeeded(trigger = "history-recovery") { - if (!currentGraph || isRecoveringHistory) { - return !isRecoveringHistory; - } - - ensureCurrentGraphRuntimeState(); - const context = getContext(); - const chat = context?.chat; - if (!Array.isArray(chat)) return true; - const renderLimitedGuard = getRenderLimitedHistoryRecoveryGuard(chat); - if (renderLimitedGuard.blocked) { - currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( - "paused", - { - fromFloor: currentGraph.historyState?.historyDirtyFrom ?? null, - path: "render-limit-guard", - detectionSource: - currentGraph.historyState?.lastMutationSource || "render-limit-guard", - reason: renderLimitedGuard.message, - resultCode: "history.recovery.paused.render-limit", - chatLength: renderLimitedGuard.chatLength, - renderLimit: renderLimitedGuard.renderLimit, - highestProcessedFloor: renderLimitedGuard.highestProcessedFloor, - }, - ); - notifyRenderLimitedHistoryRecoveryBlocked(renderLimitedGuard, trigger); - refreshPanelLiveState(); - return false; - } - - const detection = inspectHistoryMutation(trigger); - const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom; - if (!detection.dirty && !Number.isFinite(dirtyFrom)) { - return true; - } - if (isRestoreLockActive()) { - return false; - } - - enterRestoreLock("history-recovery", trigger); - isRecoveringHistory = true; - clearInjectionState(); - - const chatId = getCurrentChatId(context); - const settings = getSettings(); - const initialDirtyFromRaw = Number.isFinite(dirtyFrom) - ? dirtyFrom - : detection.earliestAffectedFloor; - const initialDirtyFrom = clampRecoveryStartFloor(chat, initialDirtyFromRaw); - let replayedBatches = 0; - let usedFullRebuild = false; - let recoveryPath = "full-rebuild"; - let affectedBatchCount = 0; - const historyController = beginStageAbortController("history"); - const historySignal = historyController.signal; - - updateStageNotice( - "history", - "历史恢复中", - Number.isFinite(initialDirtyFrom) - ? `受影响起点楼层 ${initialDirtyFrom} · 正在回滚并重放` - : "正在回滚并重放受影响后缀", - "running", + return await recoverHistoryIfNeededController( { - persist: true, - busy: true, + applyRecoveryPlanToVectorState, + assertRecoveryChatStillActive, + beginStageAbortController, + buildRecoveryResult, + buildReverseJournalRecoveryPlan, + clampRecoveryStartFloor, + clearHistoryDirty, + clearInjectionState, + console, + createEmptyGraph, + ensureCurrentGraphRuntimeState, + enterRestoreLock, + findJournalRecoveryPoint, + finishStageAbortController, + getContext, + getCurrentChatId, + getCurrentGraph: () => currentGraph, + getEmbeddingConfig, + getExtractionCount: () => extractionCount, + getIsRecoveringHistory: () => isRecoveringHistory, + getRenderLimitedHistoryRecoveryGuard, + getSettings, + inspectHistoryMutation, + isAbortError, + isBackendVectorConfig, + isRestoreLockActive, + leaveRestoreLock, + maybeResumePendingAutoExtraction, + normalizeGraphRuntimeState, + notifyRenderLimitedHistoryRecoveryBlocked, + prepareVectorStateForReplay, + queueMicrotask: globalThis.queueMicrotask?.bind?.(globalThis), + refreshPanelLiveState, + replayExtractionFromHistory, + rollbackAffectedJournals, + saveGraphToChat, + setCurrentGraph: (graph) => { currentGraph = graph; }, + setExtractionCount: (count) => { extractionCount = count; }, + setIsRecoveringHistory: (value) => { isRecoveringHistory = value; }, + settleExtractionStatusAfterHistoryRecovery, + throwIfAborted, + toastr, + tryDeleteBackendVectorHashesForRecovery, + updateProcessedHistorySnapshot, + updateStageNotice, }, + { trigger }, ); - - try { - throwIfAborted(historySignal, "历史恢复已终止"); - const recoveryPoint = findJournalRecoveryPoint( - currentGraph, - initialDirtyFrom, - ); - if (recoveryPoint?.path === "reverse-journal") { - recoveryPath = "reverse-journal"; - affectedBatchCount = recoveryPoint.affectedBatchCount || 0; - const config = getEmbeddingConfig(); - const recoveryPlan = buildReverseJournalRecoveryPlan( - recoveryPoint.affectedJournals, - initialDirtyFrom, - ); - if (recoveryPlan?.valid === false) { - throw new Error( - `reverse-journal recovery plan invalid: ${ - recoveryPlan.invalidReason || "unknown" - }`, - ); - } - rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); - currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); - extractionCount = currentGraph.historyState.extractionCount || 0; - applyRecoveryPlanToVectorState(recoveryPlan, initialDirtyFrom); - - if ( - isBackendVectorConfig(config) && - recoveryPlan.backendDeleteHashes.length > 0 - ) { - updateStageNotice( - "history", - "历史恢复中", - `正在整理向量恢复状态(${recoveryPlan.backendDeleteHashes.length} 项)`, - "running", - { - persist: true, - busy: true, - }, - ); - assertRecoveryChatStillActive(chatId, "pre-backend-delete"); - await tryDeleteBackendVectorHashesForRecovery( - currentGraph.vectorIndexState.collectionId, - config, - recoveryPlan.backendDeleteHashes, - historySignal, - { - source: "history-recovery", - }, - ); - } - if (isBackendVectorConfig(config)) { - updateStageNotice( - "history", - "历史恢复中", - "正在准备向量回放状态", - "running", - { - persist: true, - busy: true, - }, - ); - } - await prepareVectorStateForReplay(false, historySignal, { - skipBackendPurge: isBackendVectorConfig(config), - }); - } else if (recoveryPoint?.path === "legacy-snapshot") { - recoveryPath = "legacy-snapshot"; - affectedBatchCount = recoveryPoint.affectedBatchCount || 0; - currentGraph = normalizeGraphRuntimeState( - recoveryPoint.snapshotBefore, - chatId, - ); - extractionCount = currentGraph.historyState.extractionCount || 0; - await prepareVectorStateForReplay(false, historySignal); - } else { - recoveryPath = "full-rebuild"; - currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); - usedFullRebuild = true; - extractionCount = 0; - await prepareVectorStateForReplay(true, historySignal); - } - - assertRecoveryChatStillActive(chatId, "pre-replay"); - replayedBatches = await replayExtractionFromHistory( - chat, - settings, - historySignal, - chatId, - ); - - clearHistoryDirty( - currentGraph, - buildRecoveryResult(usedFullRebuild ? "full-rebuild" : "replayed", { - fromFloor: initialDirtyFrom, - batches: replayedBatches, - path: recoveryPath, - detectionSource: - detection.source || - currentGraph?.historyState?.lastMutationSource || - "hash-recheck", - affectedBatchCount, - replayedBatchCount: replayedBatches, - reason: - detection.reason || - currentGraph?.historyState?.lastMutationReason || - trigger, - }), - ); - const recoveredLastProcessedFloor = Number.isFinite( - currentGraph?.historyState?.lastProcessedAssistantFloor, - ) - ? currentGraph.historyState.lastProcessedAssistantFloor - : -1; - if (recoveredLastProcessedFloor >= 0) { - // Recovery replay has rebuilt the graph state; restore processed hashes so - // the next hash recheck does not immediately trigger another replay loop. - updateProcessedHistorySnapshot(chat, recoveredLastProcessedFloor); - } - saveGraphToChat({ reason: "history-recovery-complete" }); - refreshPanelLiveState(); - settleExtractionStatusAfterHistoryRecovery( - "提取完成", - `历史恢复回放 ${replayedBatches} 批`, - "success", - ); - updateStageNotice( - "history", - usedFullRebuild ? "历史恢复完成(全量重建)" : "历史恢复完成", - `path ${recoveryPath} · 起点楼层 ${initialDirtyFrom} · 受影响 ${affectedBatchCount} 批 · 回放 ${replayedBatches} 批`, - usedFullRebuild ? "warning" : "success", - { - busy: false, - persist: false, - }, - ); - if (usedFullRebuild) { - toastr.warning("历史变化已触发全量重建"); - } - 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 = ""; - settleExtractionStatusAfterHistoryRecovery( - "提取已终止", - error?.message || "历史恢复已终止", - "warning", - ); - updateStageNotice( - "history", - "历史恢复已终止", - error?.message || "已手动终止当前恢复流程", - "warning", - { - busy: false, - persist: false, - }, - ); - saveGraphToChat({ reason: "history-recovery-aborted" }); - return false; - } - console.error("[ST-BME] 历史恢复失败,尝试全量重建:", error); - - try { - currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); - extractionCount = 0; - await prepareVectorStateForReplay(true, historySignal); - assertRecoveryChatStillActive(chatId, "pre-fallback-replay"); - replayedBatches = await replayExtractionFromHistory( - chat, - settings, - historySignal, - chatId, - ); - clearHistoryDirty( - currentGraph, - buildRecoveryResult("full-rebuild", { - fromFloor: 0, - batches: replayedBatches, - path: "full-rebuild", - detectionSource: - detection.source || - currentGraph?.historyState?.lastMutationSource || - "hash-recheck", - affectedBatchCount, - replayedBatchCount: replayedBatches, - reason: `恢复失败后兜底全量重建: ${error?.message || error}`, - debugReason: `history-recovery-fallback-full-rebuild:${recoveryPath}`, - resultCode: "history.recovery.fallback-full-rebuild", - }), - ); - const recoveredLastProcessedFloor = Number.isFinite( - currentGraph?.historyState?.lastProcessedAssistantFloor, - ) - ? currentGraph.historyState.lastProcessedAssistantFloor - : -1; - if (recoveredLastProcessedFloor >= 0) { - updateProcessedHistorySnapshot(chat, recoveredLastProcessedFloor); - } - currentGraph.vectorIndexState.lastIntegrityIssue = null; - saveGraphToChat({ reason: "history-recovery-fallback-rebuild" }); - refreshPanelLiveState(); - settleExtractionStatusAfterHistoryRecovery( - "提取完成", - `历史恢复已退化为全量重建,回放 ${replayedBatches} 批`, - "warning", - ); - updateStageNotice( - "history", - "历史恢复已退化为全量重建", - `path full-rebuild · 起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches} 批`, - "warning", - { - busy: false, - persist: false, - }, - ); - toastr.warning("历史恢复已退化为全量重建"); - return true; - } catch (fallbackError) { - currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( - "failed", - { - fromFloor: initialDirtyFrom, - path: recoveryPath, - detectionSource: - detection.source || - currentGraph?.historyState?.lastMutationSource || - "hash-recheck", - 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(); - settleExtractionStatusAfterHistoryRecovery( - "提取失败", - fallbackError?.message || String(fallbackError), - "error", - ); - updateStageNotice( - "history", - "历史恢复失败", - fallbackError?.message || String(fallbackError), - "error", - { - busy: false, - persist: false, - }, - ); - toastr.error(`历史恢复失败: ${fallbackError?.message || fallbackError}`); - return false; - } - } finally { - finishStageAbortController("history", historyController); - leaveRestoreLock("history-recovery"); - isRecoveringHistory = false; - const enqueueMicrotask = - typeof globalThis.queueMicrotask === "function" - ? globalThis.queueMicrotask.bind(globalThis) - : (task) => Promise.resolve().then(task); - enqueueMicrotask(() => { - if (typeof maybeResumePendingAutoExtraction === "function") { - void maybeResumePendingAutoExtraction("history-recovery-finished"); - } - }); - } } - function settleExtractionStatusAfterHistoryRecovery( text = "提取完成", meta = "", diff --git a/maintenance/reroll-recovery-controller.js b/maintenance/reroll-recovery-controller.js index a3a2c99..6a9f87a 100644 --- a/maintenance/reroll-recovery-controller.js +++ b/maintenance/reroll-recovery-controller.js @@ -223,3 +223,424 @@ export async function rollbackGraphForRerollController( }; } + +export async function recoverHistoryIfNeededController( + runtime, + { trigger = "history-recovery" } = {}, +) { + const { + applyRecoveryPlanToVectorState, + assertRecoveryChatStillActive, + beginStageAbortController, + buildRecoveryResult, + buildReverseJournalRecoveryPlan, + clampRecoveryStartFloor, + clearHistoryDirty, + clearInjectionState, + createEmptyGraph, + ensureCurrentGraphRuntimeState, + enterRestoreLock, + findJournalRecoveryPoint, + finishStageAbortController, + getContext, + getCurrentChatId, + getCurrentGraph, + getEmbeddingConfig, + getIsRecoveringHistory, + getRenderLimitedHistoryRecoveryGuard, + getSettings, + inspectHistoryMutation, + isAbortError, + isBackendVectorConfig, + isRestoreLockActive, + leaveRestoreLock, + maybeResumePendingAutoExtraction, + normalizeGraphRuntimeState, + notifyRenderLimitedHistoryRecoveryBlocked, + prepareVectorStateForReplay, + refreshPanelLiveState, + replayExtractionFromHistory, + rollbackAffectedJournals, + saveGraphToChat, + setCurrentGraph, + setExtractionCount, + setIsRecoveringHistory, + settleExtractionStatusAfterHistoryRecovery, + throwIfAborted, + tryDeleteBackendVectorHashesForRecovery, + updateProcessedHistorySnapshot, + updateStageNotice, + } = runtime; + const toastr = runtime.toastr || {}; + const console = runtime.console || globalThis.console; + let currentGraph = getCurrentGraph(); + if (!currentGraph || getIsRecoveringHistory()) { + return !getIsRecoveringHistory(); + } + + ensureCurrentGraphRuntimeState(); + currentGraph = getCurrentGraph(); + const context = getContext(); + const chat = context?.chat; + if (!Array.isArray(chat)) return true; + const renderLimitedGuard = getRenderLimitedHistoryRecoveryGuard(chat); + if (renderLimitedGuard.blocked) { + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( + "paused", + { + fromFloor: currentGraph.historyState?.historyDirtyFrom ?? null, + path: "render-limit-guard", + detectionSource: + currentGraph.historyState?.lastMutationSource || "render-limit-guard", + reason: renderLimitedGuard.message, + resultCode: "history.recovery.paused.render-limit", + chatLength: renderLimitedGuard.chatLength, + renderLimit: renderLimitedGuard.renderLimit, + highestProcessedFloor: renderLimitedGuard.highestProcessedFloor, + }, + ); + notifyRenderLimitedHistoryRecoveryBlocked(renderLimitedGuard, trigger); + refreshPanelLiveState(); + return false; + } + + const detection = inspectHistoryMutation(trigger); + const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom; + if (!detection.dirty && !Number.isFinite(dirtyFrom)) { + return true; + } + if (isRestoreLockActive()) { + return false; + } + + enterRestoreLock("history-recovery", trigger); + setIsRecoveringHistory(true); + clearInjectionState(); + + const chatId = getCurrentChatId(context); + const settings = getSettings(); + const initialDirtyFromRaw = Number.isFinite(dirtyFrom) + ? dirtyFrom + : detection.earliestAffectedFloor; + const initialDirtyFrom = clampRecoveryStartFloor(chat, initialDirtyFromRaw); + let replayedBatches = 0; + let usedFullRebuild = false; + let recoveryPath = "full-rebuild"; + let affectedBatchCount = 0; + const historyController = beginStageAbortController("history"); + const historySignal = historyController.signal; + + updateStageNotice( + "history", + "历史恢复中", + Number.isFinite(initialDirtyFrom) + ? `受影响起点楼层 ${initialDirtyFrom} · 正在回滚并重放` + : "正在回滚并重放受影响后缀", + "running", + { + persist: true, + busy: true, + }, + ); + + try { + throwIfAborted(historySignal, "历史恢复已终止"); + const recoveryPoint = findJournalRecoveryPoint( + currentGraph, + initialDirtyFrom, + ); + if (recoveryPoint?.path === "reverse-journal") { + recoveryPath = "reverse-journal"; + affectedBatchCount = recoveryPoint.affectedBatchCount || 0; + const config = getEmbeddingConfig(); + const recoveryPlan = buildReverseJournalRecoveryPlan( + recoveryPoint.affectedJournals, + initialDirtyFrom, + ); + if (recoveryPlan?.valid === false) { + throw new Error( + `reverse-journal recovery plan invalid: ${ + recoveryPlan.invalidReason || "unknown" + }`, + ); + } + rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); + currentGraph = getCurrentGraph(); + currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); + setCurrentGraph(currentGraph); + setExtractionCount(currentGraph.historyState.extractionCount || 0); + applyRecoveryPlanToVectorState(recoveryPlan, initialDirtyFrom); + + if ( + isBackendVectorConfig(config) && + recoveryPlan.backendDeleteHashes.length > 0 + ) { + updateStageNotice( + "history", + "历史恢复中", + `正在整理向量恢复状态(${recoveryPlan.backendDeleteHashes.length} 项)`, + "running", + { + persist: true, + busy: true, + }, + ); + assertRecoveryChatStillActive(chatId, "pre-backend-delete"); + await tryDeleteBackendVectorHashesForRecovery( + currentGraph.vectorIndexState.collectionId, + config, + recoveryPlan.backendDeleteHashes, + historySignal, + { + source: "history-recovery", + }, + ); + } + if (isBackendVectorConfig(config)) { + updateStageNotice( + "history", + "历史恢复中", + "正在准备向量回放状态", + "running", + { + persist: true, + busy: true, + }, + ); + } + await prepareVectorStateForReplay(false, historySignal, { + skipBackendPurge: isBackendVectorConfig(config), + }); + } else if (recoveryPoint?.path === "legacy-snapshot") { + recoveryPath = "legacy-snapshot"; + affectedBatchCount = recoveryPoint.affectedBatchCount || 0; + currentGraph = normalizeGraphRuntimeState( + recoveryPoint.snapshotBefore, + chatId, + ); + setCurrentGraph(currentGraph); + setExtractionCount(currentGraph.historyState.extractionCount || 0); + await prepareVectorStateForReplay(false, historySignal); + } else { + recoveryPath = "full-rebuild"; + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); + setCurrentGraph(currentGraph); + usedFullRebuild = true; + setExtractionCount(0); + await prepareVectorStateForReplay(true, historySignal); + } + + assertRecoveryChatStillActive(chatId, "pre-replay"); + replayedBatches = await replayExtractionFromHistory( + chat, + settings, + historySignal, + chatId, + ); + + clearHistoryDirty( + currentGraph, + buildRecoveryResult(usedFullRebuild ? "full-rebuild" : "replayed", { + fromFloor: initialDirtyFrom, + batches: replayedBatches, + path: recoveryPath, + detectionSource: + detection.source || + currentGraph?.historyState?.lastMutationSource || + "hash-recheck", + affectedBatchCount, + replayedBatchCount: replayedBatches, + reason: + detection.reason || + currentGraph?.historyState?.lastMutationReason || + trigger, + }), + ); + const recoveredLastProcessedFloor = Number.isFinite( + currentGraph?.historyState?.lastProcessedAssistantFloor, + ) + ? currentGraph.historyState.lastProcessedAssistantFloor + : -1; + if (recoveredLastProcessedFloor >= 0) { + // Recovery replay has rebuilt the graph state; restore processed hashes so + // the next hash recheck does not immediately trigger another replay loop. + updateProcessedHistorySnapshot(chat, recoveredLastProcessedFloor); + } + saveGraphToChat({ reason: "history-recovery-complete" }); + refreshPanelLiveState(); + settleExtractionStatusAfterHistoryRecovery( + "提取完成", + `历史恢复回放 ${replayedBatches} 批`, + "success", + ); + updateStageNotice( + "history", + usedFullRebuild ? "历史恢复完成(全量重建)" : "历史恢复完成", + `path ${recoveryPath} · 起点楼层 ${initialDirtyFrom} · 受影响 ${affectedBatchCount} 批 · 回放 ${replayedBatches} 批`, + usedFullRebuild ? "warning" : "success", + { + busy: false, + persist: false, + }, + ); + if (usedFullRebuild) { + toastr.warning("历史变化已触发全量重建"); + } + 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 = ""; + settleExtractionStatusAfterHistoryRecovery( + "提取已终止", + error?.message || "历史恢复已终止", + "warning", + ); + updateStageNotice( + "history", + "历史恢复已终止", + error?.message || "已手动终止当前恢复流程", + "warning", + { + busy: false, + persist: false, + }, + ); + saveGraphToChat({ reason: "history-recovery-aborted" }); + return false; + } + console.error("[ST-BME] 历史恢复失败,尝试全量重建:", error); + + try { + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); + setCurrentGraph(currentGraph); + setExtractionCount(0); + await prepareVectorStateForReplay(true, historySignal); + assertRecoveryChatStillActive(chatId, "pre-fallback-replay"); + replayedBatches = await replayExtractionFromHistory( + chat, + settings, + historySignal, + chatId, + ); + clearHistoryDirty( + currentGraph, + buildRecoveryResult("full-rebuild", { + fromFloor: 0, + batches: replayedBatches, + path: "full-rebuild", + detectionSource: + detection.source || + currentGraph?.historyState?.lastMutationSource || + "hash-recheck", + affectedBatchCount, + replayedBatchCount: replayedBatches, + reason: `恢复失败后兜底全量重建: ${error?.message || error}`, + debugReason: `history-recovery-fallback-full-rebuild:${recoveryPath}`, + resultCode: "history.recovery.fallback-full-rebuild", + }), + ); + const recoveredLastProcessedFloor = Number.isFinite( + currentGraph?.historyState?.lastProcessedAssistantFloor, + ) + ? currentGraph.historyState.lastProcessedAssistantFloor + : -1; + if (recoveredLastProcessedFloor >= 0) { + updateProcessedHistorySnapshot(chat, recoveredLastProcessedFloor); + } + currentGraph.vectorIndexState.lastIntegrityIssue = null; + saveGraphToChat({ reason: "history-recovery-fallback-rebuild" }); + refreshPanelLiveState(); + settleExtractionStatusAfterHistoryRecovery( + "提取完成", + `历史恢复已退化为全量重建,回放 ${replayedBatches} 批`, + "warning", + ); + updateStageNotice( + "history", + "历史恢复已退化为全量重建", + `path full-rebuild · 起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches} 批`, + "warning", + { + busy: false, + persist: false, + }, + ); + toastr.warning("历史恢复已退化为全量重建"); + return true; + } catch (fallbackError) { + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult( + "failed", + { + fromFloor: initialDirtyFrom, + path: recoveryPath, + detectionSource: + detection.source || + currentGraph?.historyState?.lastMutationSource || + "hash-recheck", + 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(); + settleExtractionStatusAfterHistoryRecovery( + "提取失败", + fallbackError?.message || String(fallbackError), + "error", + ); + updateStageNotice( + "history", + "历史恢复失败", + fallbackError?.message || String(fallbackError), + "error", + { + busy: false, + persist: false, + }, + ); + toastr.error(`历史恢复失败: ${fallbackError?.message || fallbackError}`); + return false; + } + } finally { + finishStageAbortController("history", historyController); + leaveRestoreLock("history-recovery"); + setIsRecoveringHistory(false); + const enqueueMicrotask = + typeof runtime.queueMicrotask === "function" + ? runtime.queueMicrotask + : typeof globalThis.queueMicrotask === "function" + ? globalThis.queueMicrotask.bind(globalThis) + : (task) => Promise.resolve().then(task); + enqueueMicrotask(() => { + if (typeof maybeResumePendingAutoExtraction === "function") { + void maybeResumePendingAutoExtraction("history-recovery-finished"); + } + }); + } + +} diff --git a/tests/index-slicing-ratchet.mjs b/tests/index-slicing-ratchet.mjs index a4c72d0..1f9c11b 100644 --- a/tests/index-slicing-ratchet.mjs +++ b/tests/index-slicing-ratchet.mjs @@ -28,7 +28,6 @@ const SELF_RELATIVE = "tests/index-slicing-ratchet.mjs"; // Remove the entry entirely once a file no longer reads index.js as text. const ALLOWLIST = Object.freeze({ "tests/graph-persistence.mjs": { maxMarkerCalls: 7, stage: "Phase 5" }, - "tests/p0-regressions.mjs": { maxMarkerCalls: 3, stage: "Phase 3" }, "tests/helpers/generation-recall-harness.mjs": { maxMarkerCalls: 3, stage: "Phase 4" }, "tests/index-esm-entry-smoke.mjs": { maxMarkerCalls: 4, stage: "Phase 5" }, }); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 7f2c3b7..05a52f6 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -9,7 +9,10 @@ import { toDataModuleUrl, } from "./helpers/register-hooks-compat.mjs"; import { pruneProcessedMessageHashesFromFloor } from "../maintenance/chat-history.js"; -import { rollbackGraphForRerollController } from "../maintenance/reroll-recovery-controller.js"; +import { + recoverHistoryIfNeededController, + rollbackGraphForRerollController, +} from "../maintenance/reroll-recovery-controller.js"; import { handleExtractionSuccessController, shouldAdvanceProcessedHistory, @@ -461,223 +464,277 @@ function createBatchStageHarness() { } function createHistoryRecoveryHarness() { - return fs.readFile(indexPath, "utf8").then((source) => { - const start = source.indexOf("async function recoverHistoryIfNeeded("); - const endFallback = source.indexOf("async function runExtraction()"); - const end = source.indexOf("/**\n * 提取管线:处理未提取的对话楼层"); - const resolvedEnd = end >= 0 ? end : endFallback; - if (start < 0 || resolvedEnd < 0 || resolvedEnd <= start) { - throw new Error("无法从 index.js 提取 history recovery 定义"); - } - const snippet = source - .slice(start, resolvedEnd) - .replace(/^export\s+/gm, ""); - const context = { - console, - Date, - result: null, - currentGraph: null, - extractionCount: 0, - isRecoveringHistory: false, - chat: [], - clearedHistoryDirty: null, - prepareVectorStateCalls: [], - saveGraphToChatCalls: 0, - refreshPanelCalls: 0, - renderLimitBlockedCalls: [], - notices: [], - toastCalls: { - success: [], - warning: [], - error: [], - }, - embeddingConfig: { mode: "backend" }, - isRestoreLockActive() { - return false; - }, - enterRestoreLock() {}, - leaveRestoreLock() {}, - async maybeResumePendingAutoExtraction() {}, - 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 {}; - }, - getRenderLimitedHistoryRecoveryGuard() { - return context.renderLimitedGuard || { blocked: false }; - }, - notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger) { - context.renderLimitBlockedCalls.push({ guard, trigger }); - }, - 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; - }, - updateProcessedHistorySnapshot(chat, lastProcessedAssistantFloor) { - context.updatedProcessedHistorySnapshot = { - chatLength: Array.isArray(chat) ? chat.length : 0, - lastProcessedAssistantFloor, - }; - context.currentGraph.historyState ||= {}; - context.currentGraph.historyState.lastProcessedAssistantFloor = - lastProcessedAssistantFloor; - context.currentGraph.historyState.processedMessageHashes = - lastProcessedAssistantFloor >= 0 - ? { [lastProcessedAssistantFloor]: `hash-${lastProcessedAssistantFloor}` } - : {}; - }, - 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(...args) { - context.toastCalls.success.push(args); - }, - warning(...args) { - context.toastCalls.warning.push(args); - }, - error(...args) { - context.toastCalls.error.push(args); - }, - }, + const context = { + console, + Date, + result: null, + currentGraph: null, + extractionCount: 0, + isRecoveringHistory: false, + chat: [], + clearedHistoryDirty: null, + prepareVectorStateCalls: [], + saveGraphToChatCalls: 0, + refreshPanelCalls: 0, + renderLimitBlockedCalls: [], + notices: [], + toastCalls: { + success: [], + warning: [], + error: [], + }, + embeddingConfig: { mode: "backend" }, + isRestoreLockActive() { + return false; + }, + enterRestoreLock() {}, + leaveRestoreLock() {}, + async maybeResumePendingAutoExtraction() {}, + ensureCurrentGraphRuntimeState() { + return context.currentGraph; + }, + beginStageAbortController() { + return { + signal: { aborted: false }, + abort() {}, }; - vm.createContext(context); - vm.runInContext( - `${snippet}\nresult = { recoverFromHistoryMutation: recoverHistoryIfNeeded };`, - context, - { filename: indexPath }, - ); - return context; - }); + }, + 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 {}; + }, + getRenderLimitedHistoryRecoveryGuard() { + return context.renderLimitedGuard || { blocked: false }; + }, + notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger) { + context.renderLimitBlockedCalls.push({ guard, trigger }); + }, + 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; + }, + updateProcessedHistorySnapshot(chat, lastProcessedAssistantFloor) { + context.updatedProcessedHistorySnapshot = { + chatLength: Array.isArray(chat) ? chat.length : 0, + lastProcessedAssistantFloor, + }; + context.currentGraph.historyState ||= {}; + context.currentGraph.historyState.lastProcessedAssistantFloor = + lastProcessedAssistantFloor; + context.currentGraph.historyState.processedMessageHashes = + lastProcessedAssistantFloor >= 0 + ? { [lastProcessedAssistantFloor]: `hash-${lastProcessedAssistantFloor}` } + : {}; + }, + 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(...args) { + context.toastCalls.success.push(args); + }, + warning(...args) { + context.toastCalls.warning.push(args); + }, + error(...args) { + context.toastCalls.error.push(args); + }, + }, +}; + + const runtime = { + applyRecoveryPlanToVectorState: (...args) => + context.applyRecoveryPlanToVectorState(...args), + assertRecoveryChatStillActive: (...args) => + context.assertRecoveryChatStillActive(...args), + beginStageAbortController: (...args) => context.beginStageAbortController(...args), + buildRecoveryResult: (...args) => context.buildRecoveryResult(...args), + buildReverseJournalRecoveryPlan: (...args) => + context.buildReverseJournalRecoveryPlan(...args), + clampRecoveryStartFloor: (...args) => context.clampRecoveryStartFloor(...args), + clearHistoryDirty: (...args) => context.clearHistoryDirty(...args), + clearInjectionState: (...args) => context.clearInjectionState(...args), + console: context.console, + createEmptyGraph: (...args) => context.createEmptyGraph(...args), + ensureCurrentGraphRuntimeState: (...args) => + context.ensureCurrentGraphRuntimeState(...args), + enterRestoreLock: (...args) => context.enterRestoreLock(...args), + findJournalRecoveryPoint: (...args) => context.findJournalRecoveryPoint(...args), + finishStageAbortController: (...args) => + context.finishStageAbortController(...args), + getContext: (...args) => context.getContext(...args), + getCurrentChatId: (...args) => context.getCurrentChatId(...args), + getCurrentGraph: () => context.currentGraph, + getEmbeddingConfig: (...args) => context.getEmbeddingConfig(...args), + getExtractionCount: () => context.extractionCount, + getIsRecoveringHistory: () => context.isRecoveringHistory, + getRenderLimitedHistoryRecoveryGuard: (...args) => + context.getRenderLimitedHistoryRecoveryGuard(...args), + getSettings: (...args) => context.getSettings(...args), + inspectHistoryMutation: (...args) => context.inspectHistoryMutation(...args), + isAbortError: (...args) => context.isAbortError(...args), + isBackendVectorConfig: (...args) => context.isBackendVectorConfig(...args), + isRestoreLockActive: (...args) => context.isRestoreLockActive(...args), + leaveRestoreLock: (...args) => context.leaveRestoreLock(...args), + maybeResumePendingAutoExtraction: (...args) => + context.maybeResumePendingAutoExtraction(...args), + normalizeGraphRuntimeState: (...args) => context.normalizeGraphRuntimeState(...args), + notifyRenderLimitedHistoryRecoveryBlocked: (...args) => + context.notifyRenderLimitedHistoryRecoveryBlocked(...args), + prepareVectorStateForReplay: (...args) => + context.prepareVectorStateForReplay(...args), + queueMicrotask, + refreshPanelLiveState: (...args) => context.refreshPanelLiveState(...args), + replayExtractionFromHistory: (...args) => + context.replayExtractionFromHistory(...args), + rollbackAffectedJournals: (...args) => context.rollbackAffectedJournals(...args), + saveGraphToChat: (...args) => context.saveGraphToChat(...args), + setCurrentGraph: (graph) => { + context.currentGraph = graph; + }, + setExtractionCount: (count) => { + context.extractionCount = count; + }, + setIsRecoveringHistory: (value) => { + context.isRecoveringHistory = value; + }, + settleExtractionStatusAfterHistoryRecovery: (...args) => { + context.settledExtractionStatus = args; + }, + throwIfAborted: (...args) => context.throwIfAborted(...args), + toastr: context.toastr, + tryDeleteBackendVectorHashesForRecovery: (...args) => + context.deleteBackendVectorHashesForRecovery(...args), + updateProcessedHistorySnapshot: (...args) => + context.updateProcessedHistorySnapshot(...args), + updateStageNotice: (...args) => context.updateStageNotice(...args), + }; + context.result = { + recoverFromHistoryMutation: (trigger = "history-recovery") => + recoverHistoryIfNeededController(runtime, { trigger }), + }; + return Promise.resolve(context); } function createHistoryNotificationHarness() {