Fix persistence pending gating and add repair actions

This commit is contained in:
Youzini-afk
2026-04-11 00:20:51 +08:00
parent b3d7399abe
commit f37c5de761
8 changed files with 356 additions and 24 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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") {

View File

@@ -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");

View File

@@ -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");

View File

@@ -204,6 +204,20 @@
<label>最近持久化</label>
<div class="bme-recent-meta" id="bme-status-last-persist"></div>
</div>
<div class="bme-config-row" id="bme-persist-repair-row" hidden>
<label>持久化修复</label>
<div class="bme-task-inline-actions">
<button class="bme-config-secondary-btn" id="bme-act-retry-persist" type="button">
<i class="fa-solid fa-arrows-rotate"></i>
<span>重试持久化</span>
</button>
<button class="bme-config-secondary-btn" id="bme-act-probe-graph-load" type="button">
<i class="fa-solid fa-satellite-dish"></i>
<span>重新探测图谱</span>
</button>
</div>
</div>
<div class="bme-config-help" id="bme-persist-repair-help" hidden></div>
<div class="bme-config-row">
<label>最近向量</label>
<div class="bme-recent-meta" id="bme-status-last-vector"></div>

View File

@@ -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 ||

View File

@@ -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: [],