From f37c5de761aa3e4508fb98a0e8e274335a89d156 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 00:20:51 +0800 Subject: [PATCH] Fix persistence pending gating and add repair actions --- index.js | 49 +++++++++ maintenance/extraction-controller.js | 35 ++++++- runtime/runtime-state.js | 38 ++++--- tests/extraction-persistence-gating.mjs | 30 ++++++ tests/mobile-status-regressions.mjs | 126 ++++++++++++++++++++++++ ui/panel.html | 14 +++ ui/panel.js | 80 ++++++++++++++- ui/ui-status.js | 8 +- 8 files changed, 356 insertions(+), 24 deletions(-) diff --git a/index.js b/index.js index 2a0a33c..0991fc9 100644 --- a/index.js +++ b/index.js @@ -13229,6 +13229,53 @@ async function onRollbackLastRestore() { }); return { handledToast: true, result }; } + +async function onRetryPendingPersist() { + const hadPending = graphPersistenceState.pendingPersist === true; + const result = await retryPendingGraphPersist({ + reason: "panel-manual-persist-retry", + scheduleRetryOnFailure: false, + }); + refreshPanelLiveState(); + + if (result?.accepted === true) { + toastr.success("最近一批持久化已确认"); + return { handledToast: true, result }; + } + + if (!hadPending && String(result?.reason || "") === "no-pending-persist") { + toastr.info("当前没有待确认的持久化批次"); + return { handledToast: true, result }; + } + + toastr.warning( + `持久化仍未确认: ${result?.reason || result?.loadState || "未知原因"}`, + ); + return { handledToast: true, result }; +} + +async function onProbeGraphLoad() { + const result = syncGraphLoadFromLiveContext({ + source: "panel-manual-graph-probe", + force: true, + }); + refreshPanelLiveState(); + + if (graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING) { + toastr.info("已重新探测当前聊天图谱,正在等待本地持久化加载"); + return { handledToast: true, result }; + } + + if (graphPersistenceState.loadState === GRAPH_LOAD_STATES.BLOCKED) { + toastr.warning( + `当前图谱仍处于保护模式: ${graphPersistenceState.reason || "metadata not ready"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已重新探测当前聊天图谱"); + return { handledToast: true, result }; +} (async function init() { await loadServerSettings(); syncGraphPersistenceDebugState(); @@ -13248,6 +13295,8 @@ async function onRollbackLastRestore() { summaryRollup: onManualSummaryRollup, rebuildSummaryState: onRebuildSummaryState, clearSummaryState: onClearSummaryState, + retryPendingPersist: onRetryPendingPersist, + probeGraphLoad: onProbeGraphLoad, export: onExportGraph, import: onImportGraph, rebuild: onRebuild, diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 5e5b497..03d8f59 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -85,9 +85,27 @@ function normalizePersistenceStateRecord(persistResult = null) { saved: persistResult?.saved === true, queued, blocked, + attempted: true, }; } +function hasMeaningfulPersistenceRecord(persistence = null) { + if (!persistence || typeof persistence !== "object") return false; + if (persistence.attempted === true) return true; + const revision = Number(persistence?.revision || 0); + if (Number.isFinite(revision) && revision > 0) return true; + if (String(persistence?.storageTier || "").trim() && persistence.storageTier !== "none") { + return true; + } + if (String(persistence?.saveMode || "").trim()) return true; + if (String(persistence?.reason || "").trim()) return true; + return ( + persistence.saved === true || + persistence.queued === true || + persistence.blocked === true + ); +} + function cloneSerializable(value, fallback = null) { try { return JSON.parse(JSON.stringify(value)); @@ -346,13 +364,15 @@ function getPendingPersistenceGateInfo(runtime) { const persistence = batchStatus?.persistence || null; const pendingPersist = runtime?.getGraphPersistenceState?.()?.pendingPersist === true; const accepted = isPersistenceRevisionAccepted(runtime, persistence); - if (!pendingPersist && (!persistence || accepted)) { + const attempted = hasMeaningfulPersistenceRecord(persistence); + if (!pendingPersist && (!attempted || accepted)) { return null; } return { pendingPersist, accepted, + attempted, outcome: String(persistence?.outcome || ""), reason: String(persistence?.reason || ""), revision: Number.isFinite(Number(persistence?.revision)) @@ -387,7 +407,7 @@ function formatPendingPersistenceGateMessage(runtime, operationLabel = "当前 Number.isFinite(Number(gate.revision)) && Number(gate.revision) > 0 ? ` · rev ${Number(gate.revision)}` : ""; - return `${operationLabel}已暂停:上一批持久化尚未确认,请优先重试持久化或触发恢复${revision}${reason}`; + return `${operationLabel}已暂停:上一批持久化尚未确认,请先使用“重试持久化”或“重新探测图谱”${revision}${reason}`; } export function resolveAutoExtractionPlanController( @@ -571,6 +591,15 @@ export async function executeExtractionBatchController( "failed", result?.error || "提取阶段未返回有效操作", ); + runtime.setBatchStageOutcome( + batchStatus, + "finalize", + "failed", + "提取阶段失败,未进入持久化", + ); + batchStatus.persistence = null; + batchStatus.historyAdvanceAllowed = false; + batchStatus.historyAdvanced = false; runtime.finalizeBatchStatus(batchStatus, runtime.getExtractionCount()); runtime.getCurrentGraph().historyState.lastBatchStatus = batchStatus; return { @@ -852,7 +881,7 @@ export async function onManualExtractController(runtime, options = {}) { syncRuntime: true, }, ); - runtime.toastr.warning("上一批持久化尚未确认,请先重试持久化或执行恢复"); + runtime.toastr.warning("上一批持久化尚未确认,请先点“重试持久化”或“重新探测图谱”"); return; } if (!(await runtime.recoverHistoryIfNeeded("manual-extract"))) return; diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index dfb5465..20f4999 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -137,7 +137,7 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { !Array.isArray(historyState.lastBatchStatus.persistence) ? { outcome: String( - historyState.lastBatchStatus.persistence.outcome || "queued", + historyState.lastBatchStatus.persistence.outcome || "failed", ), accepted: historyState.lastBatchStatus.persistence.accepted === true, @@ -161,18 +161,32 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { historyState.lastBatchStatus.persistence.queued === true, blocked: historyState.lastBatchStatus.persistence.blocked === true, + attempted: + historyState.lastBatchStatus.persistence.attempted === true || + Number(historyState.lastBatchStatus.persistence.revision) > 0 || + Boolean( + String( + historyState.lastBatchStatus.persistence.storageTier || "", + ).trim() && + String( + historyState.lastBatchStatus.persistence.storageTier || "", + ) !== "none", + ) || + Boolean( + String( + historyState.lastBatchStatus.persistence.saveMode || "", + ).trim(), + ) || + Boolean( + String( + historyState.lastBatchStatus.persistence.reason || "", + ).trim(), + ) || + historyState.lastBatchStatus.persistence.saved === true || + historyState.lastBatchStatus.persistence.queued === true || + historyState.lastBatchStatus.persistence.blocked === true, } - : { - outcome: "queued", - accepted: false, - storageTier: "none", - reason: "", - revision: 0, - saveMode: "", - saved: false, - queued: false, - blocked: false, - }, + : null, }; } if (typeof historyState.lastExtractedRegion !== "string") { diff --git a/tests/extraction-persistence-gating.mjs b/tests/extraction-persistence-gating.mjs index e90c0c0..897b09d 100644 --- a/tests/extraction-persistence-gating.mjs +++ b/tests/extraction-persistence-gating.mjs @@ -182,4 +182,34 @@ function createRuntime(persistResult) { ); } +{ + const runtime = createRuntime({ + saved: false, + queued: false, + blocked: false, + accepted: false, + reason: "should-not-run", + revision: 0, + saveMode: "", + storageTier: "none", + }); + runtime.extractMemories = async () => ({ + success: false, + error: "提取 LLM 未返回有效操作", + processedRange: [4, 4], + }); + const result = await executeExtractionBatchController(runtime, { + chat: [{ is_user: false, mes: "测试" }], + startIdx: 5, + endIdx: 5, + settings: {}, + }); + + assert.equal(result.success, false); + assert.equal(result.batchStatus.completed, false); + assert.equal(result.batchStatus.stages.core.outcome, "failed"); + assert.equal(result.batchStatus.stages.finalize.outcome, "failed"); + assert.equal(runtime.graph.historyState.lastBatchStatus.persistence, null); +} + console.log("extraction-persistence-gating tests passed"); diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index cf809d7..6a7e49f 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -329,6 +329,131 @@ async function testManualExtractIgnoresSupersededPendingPersistence() { assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认"); } +async function testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt() { + let executeExtractionBatchCalls = 0; + const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; + const context = { + ...createBaseStatusContext(), + isExtracting: false, + graphPersistenceState: { + pendingPersist: false, + lastAcceptedRevision: 0, + }, + currentGraph: { + historyState: { + lastBatchStatus: { + outcome: "failed", + processedRange: [1, 1], + persistence: { + outcome: "queued", + accepted: false, + revision: 0, + reason: "", + storageTier: "none", + }, + }, + }, + }, + getCurrentChatId() { + return "chat-mobile"; + }, + getCurrentGraph() { + return context.currentGraph; + }, + getIsExtracting() { + return context.isExtracting; + }, + getGraphPersistenceState() { + return { + pendingPersist: false, + lastAcceptedRevision: 0, + }; + }, + ensureGraphMutationReady() { + return true; + }, + async recoverHistoryIfNeeded() { + return true; + }, + normalizeGraphRuntimeState(graph) { + return graph; + }, + setCurrentGraph(graph) { + context.currentGraph = graph; + }, + createEmptyGraph() { + return {}; + }, + getContext() { + return { chat }; + }, + getAssistantTurns() { + return [1]; + }, + getLastProcessedAssistantFloor() { + return 0; + }, + clampInt(value, fallback) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; + }, + getSettings() { + return { extractEvery: 1 }; + }, + beginStageAbortController() { + return { signal: {} }; + }, + async executeExtractionBatch() { + executeExtractionBatchCalls += 1; + return { + success: true, + result: { + newNodes: 0, + updatedNodes: 0, + newEdges: 0, + }, + effects: {}, + batchStatus: { + persistence: { + accepted: true, + revision: 1, + attempted: true, + }, + }, + historyAdvanceAllowed: true, + }; + }, + async retryPendingGraphPersist() { + return { + accepted: false, + reason: "no-pending-persist", + }; + }, + isAbortError() { + return false; + }, + onManualExtractController, + finishStageAbortController() {}, + setIsExtracting(value) { + context.isExtracting = value; + }, + setLastExtractionStatus(text, meta, level) { + context.lastExtractionStatus = { text, meta, level }; + context.runtimeStatus = { text, meta, level }; + }, + toastr: { + info() {}, + success() {}, + warning() {}, + error() {}, + }, + result: null, + }; + + await onManualExtractController(context, { drainAll: false }); + assert.equal(executeExtractionBatchCalls, 1); + assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认"); +} + async function testManualRebuildSetsTerminalRuntimeStatus() { const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; const context = { @@ -405,6 +530,7 @@ testIndexDefinesLastProcessedAssistantFloorHelper(); await testVectorSyncTerminalStateUpdatesRuntime(); await testManualExtractNoBatchesDoesNotStayRunning(); await testManualExtractIgnoresSupersededPendingPersistence(); +await testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt(); await testManualRebuildSetsTerminalRuntimeStatus(); console.log("mobile-status-regressions tests passed"); diff --git a/ui/panel.html b/ui/panel.html index 8eaf33a..2121291 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -204,6 +204,20 @@
+ +
diff --git a/ui/panel.js b/ui/panel.js index e40f9dd..5ab4d72 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -1832,6 +1832,7 @@ function _refreshDashboard() { _setText("bme-status-last-persist", "等待聊天图谱元数据加载"); _setText("bme-status-last-vector", "等待聊天图谱元数据加载"); _setText("bme-status-last-recall", "等待聊天图谱元数据加载"); + _refreshPersistenceRepairUi(loadInfo, null); _renderStatefulListPlaceholder( document.getElementById("bme-recent-extract"), _getGraphLoadLabel(loadInfo.loadState), @@ -1908,6 +1909,7 @@ function _refreshDashboard() { "bme-status-last-persist", _formatDashboardPersistMeta(loadInfo, lastBatchStatus), ); + _refreshPersistenceRepairUi(loadInfo, lastBatchStatus); _setText("bme-status-last-vector", vectorStatus.meta || "尚未执行向量任务"); _setText("bme-status-last-recall", recallStatus.meta || "尚未执行召回"); @@ -3739,6 +3741,8 @@ function _bindActions() { "bme-act-sleep": "sleep", "bme-act-synopsis": "synopsis", "bme-act-summary-rollup": "summaryRollup", + "bme-act-retry-persist": "retryPendingPersist", + "bme-act-probe-graph-load": "probeGraphLoad", "bme-act-export": "export", "bme-act-import": "import", "bme-act-rebuild": "rebuild", @@ -3763,6 +3767,8 @@ function _bindActions() { sleep: "执行遗忘", synopsis: "生成小总结", summaryRollup: "执行总结折叠", + retryPendingPersist: "重试持久化", + probeGraphLoad: "重新探测图谱", rebuildSummaryState: "重建总结状态", export: "导出图谱", import: "导入图谱", @@ -9504,6 +9510,8 @@ function _formatPersistenceOutcomeLabel(outcome = "") { return "已保存"; case "fallback": return "兜底已保存"; + case "not-attempted": + return "未尝试"; case "queued": return "已排队"; case "blocked": @@ -9515,8 +9523,26 @@ function _formatPersistenceOutcomeLabel(outcome = "") { } } +function _hasMeaningfulPersistenceRecord(persistence = null) { + if (!persistence || typeof persistence !== "object") return false; + if (persistence.attempted === true) return true; + const revision = Number(persistence?.revision || 0); + if (Number.isFinite(revision) && revision > 0) return true; + if (String(persistence?.storageTier || "").trim() && persistence.storageTier !== "none") { + return true; + } + if (String(persistence?.saveMode || "").trim()) return true; + if (String(persistence?.reason || "").trim()) return true; + return ( + persistence.saved === true || + persistence.queued === true || + persistence.blocked === true + ); +} + function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { if (!persistence || persistence.accepted === true) return true; + if (!_hasMeaningfulPersistenceRecord(persistence)) return true; if (loadInfo?.pendingPersist === true) return false; const persistenceRevision = Number(persistence?.revision || 0); if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { @@ -9535,7 +9561,7 @@ function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { const persistence = batchStatus?.persistence || null; - if (persistence) { + if (_hasMeaningfulPersistenceRecord(persistence)) { const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ accepted ? "已确认" : _formatPersistenceOutcomeLabel(persistence.outcome), @@ -9562,6 +9588,14 @@ function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { .join(" · "); } + if (loadInfo?.persistMismatchReason) { + return `一致性异常 · ${String(loadInfo.persistMismatchReason || "")}`; + } + + if (String(batchStatus?.outcome || "") === "failed") { + return "本批未进入持久化"; + } + return "尚未执行持久化"; } @@ -9578,7 +9612,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = ? Number(processedRange[1]) : null; - if (persistence && !accepted && pendingFloor != null) { + if (_hasMeaningfulPersistenceRecord(persistence) && !accepted && pendingFloor != null) { return `持久化待确认:本地已抽取到楼层 ${pendingFloor},已确认楼层 ${lastConfirmedFloor}`; } @@ -9586,6 +9620,10 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = return `持久化一致性异常:${String(loadInfo.persistMismatchReason || "")} · 已确认楼层 ${lastConfirmedFloor}`; } + if (String(batchStatus?.outcome || "") === "failed") { + return `最近一批提取失败,已确认处理到楼层 ${lastConfirmedFloor}`; + } + const dirtyFrom = graph?.historyState?.historyDirtyFrom; if (Number.isFinite(dirtyFrom)) { return `脏区从楼层 ${dirtyFrom} 开始,已确认处理到楼层 ${lastConfirmedFloor}`; @@ -9612,6 +9650,44 @@ function _getGraphLoadLabel(loadState = "") { } } +function _refreshPersistenceRepairUi( + loadInfo = _getGraphPersistenceSnapshot(), + batchStatus = _getLatestBatchStatusSnapshot(), +) { + const row = document.getElementById("bme-persist-repair-row"); + const help = document.getElementById("bme-persist-repair-help"); + if (!row || !help) return; + + const persistence = batchStatus?.persistence || null; + const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); + const shouldShow = + loadInfo?.pendingPersist === true || + Boolean(loadInfo?.persistMismatchReason) || + (_hasMeaningfulPersistenceRecord(persistence) && !accepted); + + row.hidden = !shouldShow; + help.hidden = !shouldShow; + if (!shouldShow) { + help.textContent = ""; + return; + } + + if (loadInfo?.pendingPersist === true) { + help.textContent = + "最近一批提取已经完成,但正式写回还没确认。先试“重试持久化”,如果状态没变化,再试“重新探测图谱”。"; + return; + } + + if (loadInfo?.persistMismatchReason) { + help.textContent = + `检测到持久化一致性异常:${String(loadInfo.persistMismatchReason || "")}。建议先重新探测图谱;如果仍异常,再执行重建或恢复。`; + return; + } + + help.textContent = + "最近一批持久化没有被接受。可以先重试持久化;如果宿主延迟加载了本地存储,再重新探测图谱。"; +} + function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) { return ( loadInfo.dbReady === true || diff --git a/ui/ui-status.js b/ui/ui-status.js index b80110a..9e7412f 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -142,13 +142,7 @@ export function createBatchStatusSkeleton({ outcome: "success", consistency: "strong", completed: false, - persistence: { - outcome: "queued", - accepted: false, - storageTier: "none", - reason: "", - revision: 0, - }, + persistence: null, historyAdvanceAllowed: false, warnings: [], errors: [],