mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Fix pending persistence confirmation deadlocks
This commit is contained in:
494
index.js
494
index.js
@@ -627,6 +627,9 @@ let pendingHistoryRecoveryTrigger = "";
|
||||
let pendingHistoryMutationCheckTimers = [];
|
||||
let pendingGraphLoadRetryTimer = null;
|
||||
let pendingGraphLoadRetryChatId = "";
|
||||
let pendingGraphPersistRetryTimer = null;
|
||||
let pendingGraphPersistRetryChatId = "";
|
||||
let pendingGraphPersistRetryAttempt = 0;
|
||||
let pendingAutoExtractionTimer = null;
|
||||
let pendingAutoExtraction = {
|
||||
chatId: "",
|
||||
@@ -684,6 +687,8 @@ const bmeIndexedDbLoadInFlightByChatId = new Map();
|
||||
const bmeIndexedDbWriteInFlightByChatId = new Map();
|
||||
const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map();
|
||||
const bmeIndexedDbLatestQueuedRevisionByChatId = new Map();
|
||||
const PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS = [500, 1500, 5000];
|
||||
const PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS = 5;
|
||||
const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([
|
||||
GRAPH_LOAD_STATES.LOADING,
|
||||
GRAPH_LOAD_STATES.BLOCKED,
|
||||
@@ -5905,6 +5910,223 @@ function maybeCaptureGraphShadowSnapshot(reason = "runtime-shadow") {
|
||||
});
|
||||
}
|
||||
|
||||
function clearPendingGraphPersistRetry({ resetChatId = true } = {}) {
|
||||
if (pendingGraphPersistRetryTimer) {
|
||||
clearTimeout(pendingGraphPersistRetryTimer);
|
||||
pendingGraphPersistRetryTimer = null;
|
||||
}
|
||||
pendingGraphPersistRetryAttempt = 0;
|
||||
if (resetChatId) {
|
||||
pendingGraphPersistRetryChatId = "";
|
||||
}
|
||||
}
|
||||
|
||||
function canPersistGraphToMetadataFallback(
|
||||
context = getContext(),
|
||||
graph = currentGraph,
|
||||
) {
|
||||
if (isGraphMetadataWriteAllowed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeChatId = normalizeChatIdCandidate(getCurrentChatId(context));
|
||||
if (!context || !graph || !activeChatId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const identity = resolveCurrentChatIdentity(context);
|
||||
const runtimeGraphChatId = normalizeChatIdCandidate(
|
||||
graph?.historyState?.chatId,
|
||||
);
|
||||
const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId);
|
||||
const sameRuntimeChat =
|
||||
!runtimeGraphChatId ||
|
||||
areChatIdsEquivalentForResolvedIdentity(
|
||||
runtimeGraphChatId,
|
||||
activeChatId,
|
||||
identity,
|
||||
) ||
|
||||
areChatIdsEquivalentForResolvedIdentity(
|
||||
activeChatId,
|
||||
runtimeGraphChatId,
|
||||
identity,
|
||||
);
|
||||
const sameStateChat =
|
||||
!stateChatId ||
|
||||
areChatIdsEquivalentForResolvedIdentity(
|
||||
stateChatId,
|
||||
activeChatId,
|
||||
identity,
|
||||
) ||
|
||||
areChatIdsEquivalentForResolvedIdentity(
|
||||
activeChatId,
|
||||
stateChatId,
|
||||
identity,
|
||||
);
|
||||
|
||||
return (
|
||||
graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT &&
|
||||
sameRuntimeChat &&
|
||||
sameStateChat &&
|
||||
typeof graph === "object" &&
|
||||
graph !== null
|
||||
);
|
||||
}
|
||||
|
||||
function buildBatchPersistenceRecordFromPersistResult(persistResult = null) {
|
||||
const accepted = persistResult?.accepted === true;
|
||||
const queued = persistResult?.queued === true;
|
||||
const blocked = persistResult?.blocked === true;
|
||||
let outcome = "failed";
|
||||
|
||||
if (accepted && String(persistResult?.storageTier || "") === "indexeddb") {
|
||||
outcome = "saved";
|
||||
} else if (accepted) {
|
||||
outcome = "fallback";
|
||||
} else if (queued) {
|
||||
outcome = "queued";
|
||||
} else if (blocked) {
|
||||
outcome = "blocked";
|
||||
}
|
||||
|
||||
return {
|
||||
outcome,
|
||||
accepted,
|
||||
storageTier: String(persistResult?.storageTier || "none"),
|
||||
reason: String(persistResult?.reason || ""),
|
||||
revision: Number.isFinite(Number(persistResult?.revision))
|
||||
? Number(persistResult.revision)
|
||||
: 0,
|
||||
saveMode: String(persistResult?.saveMode || ""),
|
||||
saved: persistResult?.saved === true,
|
||||
queued,
|
||||
blocked,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePendingPersistLastProcessedAssistantFloor() {
|
||||
const processedRange = Array.isArray(
|
||||
currentGraph?.historyState?.lastBatchStatus?.processedRange,
|
||||
)
|
||||
? currentGraph.historyState.lastBatchStatus.processedRange
|
||||
: [];
|
||||
const rangeEnd = Number(processedRange[1]);
|
||||
if (Number.isFinite(rangeEnd) && rangeEnd >= 0) {
|
||||
return Math.floor(rangeEnd);
|
||||
}
|
||||
|
||||
const rangeStart = Number(processedRange[0]);
|
||||
if (Number.isFinite(rangeStart) && rangeStart >= 0) {
|
||||
return Math.floor(rangeStart);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAcceptedPendingPersistState(
|
||||
persistResult,
|
||||
{
|
||||
lastProcessedAssistantFloor = resolvePendingPersistLastProcessedAssistantFloor(),
|
||||
} = {},
|
||||
) {
|
||||
ensureCurrentGraphRuntimeState();
|
||||
|
||||
const persistenceRecord = buildBatchPersistenceRecordFromPersistResult(
|
||||
persistResult,
|
||||
);
|
||||
const batchStatus = currentGraph?.historyState?.lastBatchStatus;
|
||||
if (batchStatus && typeof batchStatus === "object") {
|
||||
batchStatus.persistence = persistenceRecord;
|
||||
batchStatus.historyAdvanceAllowed = persistenceRecord.accepted === true;
|
||||
batchStatus.historyAdvanced = persistenceRecord.accepted === true;
|
||||
currentGraph.historyState.lastBatchStatus = batchStatus;
|
||||
}
|
||||
|
||||
if (
|
||||
persistenceRecord.accepted === true &&
|
||||
Number.isFinite(Number(lastProcessedAssistantFloor)) &&
|
||||
Number(lastProcessedAssistantFloor) >= 0
|
||||
) {
|
||||
const chat = Array.isArray(getContext()?.chat) ? getContext().chat : [];
|
||||
const safeFloor = Math.floor(Number(lastProcessedAssistantFloor));
|
||||
if (typeof updateProcessedHistorySnapshot === "function") {
|
||||
updateProcessedHistorySnapshot(chat, safeFloor);
|
||||
} else {
|
||||
currentGraph.historyState.lastProcessedAssistantFloor = safeFloor;
|
||||
currentGraph.lastProcessedSeq = safeFloor;
|
||||
}
|
||||
}
|
||||
|
||||
if (persistenceRecord.accepted === true) {
|
||||
const safeFloor = Number.isFinite(Number(lastProcessedAssistantFloor))
|
||||
? Math.floor(Number(lastProcessedAssistantFloor))
|
||||
: null;
|
||||
if (typeof setLastExtractionStatus === "function") {
|
||||
setLastExtractionStatus(
|
||||
"持久化已确认",
|
||||
[
|
||||
safeFloor != null ? `楼层 ${safeFloor}` : "",
|
||||
`rev ${Number(persistenceRecord.revision || 0)}`,
|
||||
String(persistenceRecord.storageTier || "none"),
|
||||
persistenceRecord.reason || "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
"success",
|
||||
{ syncRuntime: true, toastKind: "" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
refreshPanelLiveState();
|
||||
}
|
||||
|
||||
function schedulePendingGraphPersistRetry(
|
||||
reason = "pending-graph-persist-retry",
|
||||
attempt = 0,
|
||||
) {
|
||||
if (!graphPersistenceState.pendingPersist) {
|
||||
clearPendingGraphPersistRetry();
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetChatId = normalizeChatIdCandidate(
|
||||
graphPersistenceState.queuedPersistChatId ||
|
||||
graphPersistenceState.chatId ||
|
||||
getCurrentChatId(),
|
||||
);
|
||||
if (!targetChatId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedAttempt = Math.max(0, Math.floor(Number(attempt) || 0));
|
||||
if (normalizedAttempt >= PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delayIndex = Math.min(
|
||||
normalizedAttempt,
|
||||
PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS.length - 1,
|
||||
);
|
||||
const delayMs = PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS[delayIndex];
|
||||
clearPendingGraphPersistRetry({ resetChatId: false });
|
||||
pendingGraphPersistRetryChatId = targetChatId;
|
||||
pendingGraphPersistRetryAttempt = normalizedAttempt;
|
||||
|
||||
pendingGraphPersistRetryTimer = setTimeout(() => {
|
||||
pendingGraphPersistRetryTimer = null;
|
||||
void retryPendingGraphPersist({
|
||||
reason: `${reason}:attempt-${normalizedAttempt + 1}`,
|
||||
retryAttempt: normalizedAttempt,
|
||||
scheduleRetryOnFailure: true,
|
||||
}).catch((error) => {
|
||||
console.warn("[ST-BME] 待确认持久化自动重试失败:", error);
|
||||
});
|
||||
}, delayMs);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistGraphToChatMetadata(
|
||||
context = getContext(),
|
||||
{
|
||||
@@ -5966,6 +6188,7 @@ function persistGraphToChatMetadata(
|
||||
pendingPersist: false,
|
||||
writesBlocked: false,
|
||||
});
|
||||
clearPendingGraphPersistRetry();
|
||||
removeGraphShadowSnapshot(chatId);
|
||||
updateGraphPersistenceState({
|
||||
lastPersistReason: String(reason || ""),
|
||||
@@ -6013,6 +6236,7 @@ function queueGraphPersist(
|
||||
writesBlocked: true,
|
||||
lastPersistReason: String(reason || ""),
|
||||
});
|
||||
schedulePendingGraphPersistRetry(String(reason || "graph-persist-blocked"), 0);
|
||||
|
||||
return buildGraphPersistResult({
|
||||
queued: true,
|
||||
@@ -6027,11 +6251,12 @@ function queueGraphPersist(
|
||||
}
|
||||
|
||||
function maybeFlushQueuedGraphPersist(reason = "queued-graph-persist") {
|
||||
if (!currentGraph || !isGraphMetadataWriteAllowed()) {
|
||||
const context = getContext();
|
||||
if (!currentGraph || !canPersistGraphToMetadataFallback(context)) {
|
||||
return buildGraphPersistResult({
|
||||
queued: graphPersistenceState.pendingPersist,
|
||||
blocked: !isGraphMetadataWriteAllowed(),
|
||||
reason: isGraphMetadataWriteAllowed()
|
||||
blocked: !canPersistGraphToMetadataFallback(context),
|
||||
reason: canPersistGraphToMetadataFallback(context)
|
||||
? "missing-current-graph"
|
||||
: "write-protected",
|
||||
});
|
||||
@@ -6071,13 +6296,208 @@ function maybeFlushQueuedGraphPersist(reason = "queued-graph-persist") {
|
||||
});
|
||||
}
|
||||
|
||||
return persistGraphToChatMetadata(getContext(), {
|
||||
return persistGraphToChatMetadata(context, {
|
||||
reason,
|
||||
revision: targetRevision,
|
||||
immediate: graphPersistenceState.queuedPersistMode !== "debounced",
|
||||
});
|
||||
}
|
||||
|
||||
async function retryPendingGraphPersist({
|
||||
reason = "pending-graph-persist-retry",
|
||||
retryAttempt = 0,
|
||||
scheduleRetryOnFailure = false,
|
||||
} = {}) {
|
||||
ensureCurrentGraphRuntimeState();
|
||||
|
||||
if (!graphPersistenceState.pendingPersist) {
|
||||
clearPendingGraphPersistRetry();
|
||||
return buildGraphPersistResult({
|
||||
saved: false,
|
||||
blocked: false,
|
||||
accepted: false,
|
||||
reason: "no-pending-persist",
|
||||
revision: graphPersistenceState.revision,
|
||||
saveMode: graphPersistenceState.lastPersistMode,
|
||||
storageTier: "none",
|
||||
});
|
||||
}
|
||||
|
||||
const context = getContext();
|
||||
const activeChatId = normalizeChatIdCandidate(getCurrentChatId(context));
|
||||
const queuedChatId = normalizeChatIdCandidate(
|
||||
graphPersistenceState.queuedPersistChatId ||
|
||||
graphPersistenceState.chatId ||
|
||||
activeChatId,
|
||||
);
|
||||
const currentIdentity = resolveCurrentChatIdentity(context);
|
||||
if (!currentGraph || !context || !activeChatId || !queuedChatId) {
|
||||
if (scheduleRetryOnFailure) {
|
||||
schedulePendingGraphPersistRetry(reason, Number(retryAttempt) + 1);
|
||||
}
|
||||
return buildGraphPersistResult({
|
||||
saved: false,
|
||||
queued: true,
|
||||
blocked: true,
|
||||
accepted: false,
|
||||
reason: "pending-persist-context-unavailable",
|
||||
revision: Math.max(
|
||||
Number(graphPersistenceState.queuedPersistRevision || 0),
|
||||
Number(graphPersistenceState.revision || 0),
|
||||
),
|
||||
saveMode: graphPersistenceState.queuedPersistMode,
|
||||
storageTier: "none",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!areChatIdsEquivalentForResolvedIdentity(
|
||||
queuedChatId,
|
||||
activeChatId,
|
||||
currentIdentity,
|
||||
) &&
|
||||
!areChatIdsEquivalentForResolvedIdentity(
|
||||
activeChatId,
|
||||
queuedChatId,
|
||||
currentIdentity,
|
||||
)
|
||||
) {
|
||||
if (scheduleRetryOnFailure) {
|
||||
schedulePendingGraphPersistRetry(reason, Number(retryAttempt) + 1);
|
||||
}
|
||||
return buildGraphPersistResult({
|
||||
saved: false,
|
||||
queued: true,
|
||||
blocked: true,
|
||||
accepted: false,
|
||||
reason: "queued-chat-mismatch",
|
||||
revision: Math.max(
|
||||
Number(graphPersistenceState.queuedPersistRevision || 0),
|
||||
Number(graphPersistenceState.revision || 0),
|
||||
),
|
||||
saveMode: graphPersistenceState.queuedPersistMode,
|
||||
storageTier: "none",
|
||||
});
|
||||
}
|
||||
|
||||
const targetRevision = Math.max(
|
||||
Number(graphPersistenceState.queuedPersistRevision || 0),
|
||||
Number(graphPersistenceState.revision || 0),
|
||||
Number(graphPersistenceState.lastPersistedRevision || 0),
|
||||
Number(getGraphPersistedRevision(currentGraph) || 0),
|
||||
);
|
||||
const lastProcessedAssistantFloor =
|
||||
resolvePendingPersistLastProcessedAssistantFloor();
|
||||
|
||||
const indexedDbResult = await saveGraphToIndexedDb(activeChatId, currentGraph, {
|
||||
revision: targetRevision,
|
||||
reason,
|
||||
});
|
||||
if (indexedDbResult?.saved) {
|
||||
clearPendingGraphPersistRetry();
|
||||
persistGraphCommitMarker(context, {
|
||||
reason,
|
||||
revision: targetRevision,
|
||||
storageTier: "indexeddb",
|
||||
accepted: true,
|
||||
lastProcessedAssistantFloor,
|
||||
extractionCount,
|
||||
immediate: true,
|
||||
});
|
||||
updateGraphPersistenceState({
|
||||
pendingPersist: false,
|
||||
persistMismatchReason: "",
|
||||
lastAcceptedRevision: Math.max(
|
||||
Number(graphPersistenceState.lastAcceptedRevision || 0),
|
||||
targetRevision,
|
||||
),
|
||||
lastPersistReason: String(reason || ""),
|
||||
lastPersistMode: "indexeddb",
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
const persistResult = buildGraphPersistResult({
|
||||
saved: true,
|
||||
accepted: true,
|
||||
reason,
|
||||
revision: targetRevision,
|
||||
saveMode: "indexeddb",
|
||||
storageTier: "indexeddb",
|
||||
});
|
||||
applyAcceptedPendingPersistState(persistResult, {
|
||||
lastProcessedAssistantFloor,
|
||||
});
|
||||
void maybeResumePendingAutoExtraction("pending-persist-resolved:indexeddb");
|
||||
return persistResult;
|
||||
}
|
||||
|
||||
if (canPersistGraphToMetadataFallback(context, currentGraph)) {
|
||||
const metadataReason = `${reason}:metadata-full-fallback`;
|
||||
const metadataResult = persistGraphToChatMetadata(context, {
|
||||
reason: metadataReason,
|
||||
revision: targetRevision,
|
||||
immediate: true,
|
||||
});
|
||||
if (metadataResult?.saved) {
|
||||
clearPendingGraphPersistRetry();
|
||||
persistGraphCommitMarker(context, {
|
||||
reason: metadataReason,
|
||||
revision: targetRevision,
|
||||
storageTier: "metadata-full",
|
||||
accepted: true,
|
||||
lastProcessedAssistantFloor,
|
||||
extractionCount,
|
||||
immediate: true,
|
||||
});
|
||||
updateGraphPersistenceState({
|
||||
pendingPersist: false,
|
||||
persistMismatchReason: "",
|
||||
lastAcceptedRevision: Math.max(
|
||||
Number(graphPersistenceState.lastAcceptedRevision || 0),
|
||||
targetRevision,
|
||||
),
|
||||
lastPersistReason: metadataReason,
|
||||
lastPersistMode: String(metadataResult.saveMode || "metadata"),
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
const persistResult = buildGraphPersistResult({
|
||||
saved: true,
|
||||
accepted: true,
|
||||
reason: metadataReason,
|
||||
revision: targetRevision,
|
||||
saveMode: metadataResult.saveMode,
|
||||
storageTier: "metadata-full",
|
||||
});
|
||||
applyAcceptedPendingPersistState(persistResult, {
|
||||
lastProcessedAssistantFloor,
|
||||
});
|
||||
void maybeResumePendingAutoExtraction("pending-persist-resolved:metadata");
|
||||
return persistResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleRetryOnFailure) {
|
||||
schedulePendingGraphPersistRetry(reason, Number(retryAttempt) + 1);
|
||||
}
|
||||
return buildGraphPersistResult({
|
||||
saved: false,
|
||||
queued: true,
|
||||
blocked: true,
|
||||
accepted: false,
|
||||
reason: `${reason}:still-pending`,
|
||||
revision: targetRevision,
|
||||
saveMode: graphPersistenceState.queuedPersistMode || "immediate",
|
||||
storageTier: "none",
|
||||
});
|
||||
}
|
||||
|
||||
async function persistExtractionBatchResult({
|
||||
reason = "extraction-batch-complete",
|
||||
lastProcessedAssistantFloor = null,
|
||||
@@ -6129,7 +6549,13 @@ async function persistExtractionBatchResult({
|
||||
),
|
||||
lastPersistReason: String(reason || ""),
|
||||
lastPersistMode: "indexeddb",
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
clearPendingGraphPersistRetry();
|
||||
return buildGraphPersistResult({
|
||||
saved: true,
|
||||
accepted: true,
|
||||
@@ -6163,7 +6589,13 @@ async function persistExtractionBatchResult({
|
||||
),
|
||||
lastPersistReason: shadowReason,
|
||||
lastPersistMode: "shadow",
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
clearPendingGraphPersistRetry();
|
||||
return buildGraphPersistResult({
|
||||
saved: false,
|
||||
accepted: true,
|
||||
@@ -6174,7 +6606,7 @@ async function persistExtractionBatchResult({
|
||||
});
|
||||
}
|
||||
|
||||
if (isGraphMetadataWriteAllowed()) {
|
||||
if (canPersistGraphToMetadataFallback(context, currentGraph)) {
|
||||
const metadataReason = `${reason}:metadata-full-fallback`;
|
||||
const metadataResult = persistGraphToChatMetadata(context, {
|
||||
reason: metadataReason,
|
||||
@@ -6198,7 +6630,13 @@ async function persistExtractionBatchResult({
|
||||
Number(graphPersistenceState.lastAcceptedRevision || 0),
|
||||
revision,
|
||||
),
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
clearPendingGraphPersistRetry();
|
||||
return buildGraphPersistResult({
|
||||
saved: true,
|
||||
accepted: true,
|
||||
@@ -6213,6 +6651,7 @@ async function persistExtractionBatchResult({
|
||||
const queuedResult = queueGraphPersist(`${reason}:pending`, revision, {
|
||||
immediate: true,
|
||||
});
|
||||
schedulePendingGraphPersistRetry(`${reason}:pending`, 0);
|
||||
updateGraphPersistenceState({
|
||||
pendingPersist: true,
|
||||
lastPersistReason: String(queuedResult.reason || `${reason}:pending`),
|
||||
@@ -7714,19 +8153,33 @@ async function saveGraphToIndexedDb(
|
||||
revision,
|
||||
markSyncDirty: true,
|
||||
});
|
||||
await db.markSyncDirty(reason);
|
||||
let syncDirtyWarning = "";
|
||||
let scheduleUploadWarning = "";
|
||||
try {
|
||||
await db.markSyncDirty(reason);
|
||||
} catch (error) {
|
||||
syncDirtyWarning =
|
||||
error?.message || String(error) || "mark-sync-dirty-failed";
|
||||
console.warn("[ST-BME] IndexedDB 已写入,但补记 syncDirty 失败:", error);
|
||||
}
|
||||
|
||||
snapshot.meta.revision = normalizeIndexedDbRevision(
|
||||
importResult?.revision,
|
||||
revision,
|
||||
);
|
||||
cacheIndexedDbSnapshot(normalizedChatId, snapshot);
|
||||
scheduleUpload(
|
||||
normalizedChatId,
|
||||
buildBmeSyncRuntimeOptions({
|
||||
trigger: `graph-mutation:${String(reason || "graph-save")}`,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
scheduleUpload(
|
||||
normalizedChatId,
|
||||
buildBmeSyncRuntimeOptions({
|
||||
trigger: `graph-mutation:${String(reason || "graph-save")}`,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
scheduleUploadWarning =
|
||||
error?.message || String(error) || "schedule-upload-failed";
|
||||
console.warn("[ST-BME] IndexedDB 已写入,但同步上传调度失败:", error);
|
||||
}
|
||||
|
||||
updateGraphPersistenceState({
|
||||
storagePrimary: "indexeddb",
|
||||
@@ -7734,12 +8187,17 @@ async function saveGraphToIndexedDb(
|
||||
dbReady: true,
|
||||
lastPersistedRevision: snapshot.meta.revision,
|
||||
pendingPersist: false,
|
||||
queuedPersistRevision: 0,
|
||||
queuedPersistChatId: "",
|
||||
queuedPersistMode: "",
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
indexedDbRevision: snapshot.meta.revision,
|
||||
metadataIntegrity:
|
||||
getChatMetadataIntegrity(getContext()) ||
|
||||
graphPersistenceState.metadataIntegrity,
|
||||
indexedDbLastError: "",
|
||||
lastSyncError: "",
|
||||
lastSyncError: scheduleUploadWarning,
|
||||
dualWriteLastResult: {
|
||||
action: "save",
|
||||
target: "indexeddb",
|
||||
@@ -7747,9 +8205,13 @@ async function saveGraphToIndexedDb(
|
||||
chatId: normalizedChatId,
|
||||
revision: snapshot.meta.revision,
|
||||
reason: String(reason || "graph-save"),
|
||||
warning:
|
||||
[syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") ||
|
||||
"",
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
clearPendingGraphPersistRetry();
|
||||
if (
|
||||
graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED &&
|
||||
areChatIdsEquivalentForResolvedIdentity(
|
||||
@@ -7788,6 +8250,8 @@ async function saveGraphToIndexedDb(
|
||||
chatId: normalizedChatId,
|
||||
revision: snapshot.meta.revision,
|
||||
reason: String(reason || "graph-save"),
|
||||
warning:
|
||||
[syncDirtyWarning, scheduleUploadWarning].filter(Boolean).join(" | ") || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] IndexedDB 写入失败,保留 metadata 兜底:", error);
|
||||
@@ -10511,6 +10975,7 @@ async function runExtraction() {
|
||||
getAssistantTurns,
|
||||
getContext,
|
||||
getCurrentGraph: () => currentGraph,
|
||||
getGraphPersistenceState: () => graphPersistenceState,
|
||||
getGraphMutationBlockReason,
|
||||
getIsExtracting: () => isExtracting,
|
||||
getIsRecoveringHistory: () => isRecoveringHistory,
|
||||
@@ -10521,6 +10986,7 @@ async function runExtraction() {
|
||||
notifyExtractionIssue,
|
||||
recoverHistoryIfNeeded,
|
||||
resolveAutoExtractionPlan,
|
||||
retryPendingGraphPersist,
|
||||
setIsExtracting: (value) => {
|
||||
isExtracting = value;
|
||||
},
|
||||
@@ -11521,6 +11987,7 @@ async function onManualExtract(options = {}) {
|
||||
getContext,
|
||||
getCurrentChatId,
|
||||
getCurrentGraph: () => currentGraph,
|
||||
getGraphPersistenceState: () => graphPersistenceState,
|
||||
getIsExtracting: () => isExtracting,
|
||||
getLastProcessedAssistantFloor,
|
||||
getSettings,
|
||||
@@ -11528,6 +11995,7 @@ async function onManualExtract(options = {}) {
|
||||
normalizeGraphRuntimeState,
|
||||
recoverHistoryIfNeeded,
|
||||
refreshPanelLiveState,
|
||||
retryPendingGraphPersist,
|
||||
setCurrentGraph: (graph) => {
|
||||
currentGraph = graph;
|
||||
},
|
||||
|
||||
@@ -105,6 +105,24 @@ function getPendingPersistenceGateInfo(runtime) {
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeRetryPendingPersistence(runtime, reason = "pending-persist-retry") {
|
||||
const gate = getPendingPersistenceGateInfo(runtime);
|
||||
if (!gate || typeof runtime?.retryPendingGraphPersist !== "function") {
|
||||
return gate;
|
||||
}
|
||||
|
||||
try {
|
||||
const retryResult = await runtime.retryPendingGraphPersist({ reason });
|
||||
if (retryResult?.accepted === true) {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
runtime?.console?.warn?.("[ST-BME] pending persistence retry failed", error);
|
||||
}
|
||||
|
||||
return getPendingPersistenceGateInfo(runtime);
|
||||
}
|
||||
|
||||
function formatPendingPersistenceGateMessage(runtime, operationLabel = "当前提取") {
|
||||
const gate = getPendingPersistenceGateInfo(runtime);
|
||||
if (!gate) return "";
|
||||
@@ -430,10 +448,13 @@ export async function runExtractionController(runtime, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPersistMessage = formatPendingPersistenceGateMessage(
|
||||
const pendingPersistGate = await maybeRetryPendingPersistence(
|
||||
runtime,
|
||||
"自动提取",
|
||||
"auto-extraction-persist-retry",
|
||||
);
|
||||
const pendingPersistMessage = pendingPersistGate
|
||||
? formatPendingPersistenceGateMessage(runtime, "自动提取")
|
||||
: "";
|
||||
if (pendingPersistMessage) {
|
||||
runtime.console?.debug?.("[ST-BME] auto extraction paused: pending persistence", {
|
||||
persistence: runtime.getCurrentGraph?.()?.historyState?.lastBatchStatus?.persistence || null,
|
||||
@@ -548,10 +569,13 @@ export async function onManualExtractController(runtime, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (!runtime.ensureGraphMutationReady("手动提取")) return;
|
||||
const pendingPersistMessage = formatPendingPersistenceGateMessage(
|
||||
const pendingPersistGate = await maybeRetryPendingPersistence(
|
||||
runtime,
|
||||
"手动提取",
|
||||
"manual-extraction-persist-retry",
|
||||
);
|
||||
const pendingPersistMessage = pendingPersistGate
|
||||
? formatPendingPersistenceGateMessage(runtime, "手动提取")
|
||||
: "";
|
||||
if (pendingPersistMessage) {
|
||||
runtime.setLastExtractionStatus(
|
||||
"等待持久化确认",
|
||||
|
||||
@@ -735,7 +735,11 @@ async function createGraphPersistenceHarness({
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
buildBmeDbName,
|
||||
scheduleUpload() {},
|
||||
scheduleUpload() {
|
||||
if (runtimeContext.__scheduleUploadShouldThrow) {
|
||||
throw new Error("schedule-upload-failed");
|
||||
}
|
||||
},
|
||||
BmeDatabase: class {
|
||||
constructor(dbChatId = "") {
|
||||
this.chatId = String(dbChatId || "");
|
||||
@@ -825,7 +829,11 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
};
|
||||
},
|
||||
async markSyncDirty() {},
|
||||
async markSyncDirty() {
|
||||
if (runtimeContext.__markSyncDirtyShouldThrow) {
|
||||
throw new Error("mark-sync-dirty-failed");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
async getCurrentDb(dbChatId = this._currentChatId) {
|
||||
@@ -871,6 +879,8 @@ result = {
|
||||
onMessageReceived,
|
||||
applyGraphLoadState,
|
||||
maybeFlushQueuedGraphPersist,
|
||||
retryPendingGraphPersist,
|
||||
saveGraphToIndexedDb,
|
||||
cloneGraphForPersistence,
|
||||
assertRecoveryChatStillActive,
|
||||
createAbortError,
|
||||
@@ -2243,6 +2253,124 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-idb-ancillary-warning",
|
||||
globalChatId: "chat-idb-ancillary-warning",
|
||||
chatMetadata: {
|
||||
integrity: "meta-idb-ancillary-warning",
|
||||
},
|
||||
});
|
||||
harness.api.setCurrentGraph(
|
||||
createMeaningfulGraph("chat-idb-ancillary-warning", "ancillary-warning"),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId: "chat-idb-ancillary-warning",
|
||||
revision: 7,
|
||||
lastPersistedRevision: 0,
|
||||
writesBlocked: false,
|
||||
});
|
||||
harness.runtimeContext.__markSyncDirtyShouldThrow = true;
|
||||
harness.runtimeContext.__scheduleUploadShouldThrow = true;
|
||||
|
||||
const result = await harness.api.saveGraphToIndexedDb(
|
||||
"chat-idb-ancillary-warning",
|
||||
harness.api.getCurrentGraph(),
|
||||
{
|
||||
revision: 7,
|
||||
reason: "ancillary-warning-save",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
assert.match(String(result.warning || ""), /mark-sync-dirty-failed/);
|
||||
assert.match(String(result.warning || ""), /schedule-upload-failed/);
|
||||
assert.equal(
|
||||
harness.api.getIndexedDbSnapshot().meta.revision,
|
||||
7,
|
||||
"附属步骤失败时,IndexedDB 主写仍应视为成功",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-pending-persist-retry",
|
||||
globalChatId: "chat-pending-persist-retry",
|
||||
chatMetadata: {
|
||||
integrity: "meta-pending-persist-retry",
|
||||
},
|
||||
chat: [
|
||||
{ is_user: true, mes: "用户发言" },
|
||||
{ is_user: false, mes: "助手回复" },
|
||||
],
|
||||
});
|
||||
const graph = createMeaningfulGraph(
|
||||
"chat-pending-persist-retry",
|
||||
"pending-persist-retry",
|
||||
);
|
||||
graph.historyState.lastProcessedAssistantFloor = -1;
|
||||
graph.lastProcessedSeq = -1;
|
||||
graph.historyState.lastBatchStatus = {
|
||||
processedRange: [1, 1],
|
||||
completed: true,
|
||||
stages: {
|
||||
core: { outcome: "success" },
|
||||
finalize: { outcome: "success" },
|
||||
},
|
||||
persistence: {
|
||||
outcome: "queued",
|
||||
accepted: false,
|
||||
storageTier: "none",
|
||||
reason: "extraction-batch-complete:pending",
|
||||
revision: 7,
|
||||
saveMode: "immediate",
|
||||
saved: false,
|
||||
queued: true,
|
||||
blocked: true,
|
||||
},
|
||||
historyAdvanceAllowed: false,
|
||||
historyAdvanced: false,
|
||||
};
|
||||
harness.api.setCurrentGraph(graph);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId: "chat-pending-persist-retry",
|
||||
revision: 7,
|
||||
lastPersistedRevision: 0,
|
||||
queuedPersistRevision: 7,
|
||||
queuedPersistChatId: "chat-pending-persist-retry",
|
||||
queuedPersistMode: "immediate",
|
||||
pendingPersist: true,
|
||||
writesBlocked: false,
|
||||
});
|
||||
harness.runtimeContext.__markSyncDirtyShouldThrow = true;
|
||||
|
||||
const result = await harness.api.retryPendingGraphPersist({
|
||||
reason: "queued-persist-retry-test",
|
||||
});
|
||||
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceState().pendingPersist,
|
||||
false,
|
||||
"pendingPersist 在补存成功后应被清除",
|
||||
);
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().historyState.lastProcessedAssistantFloor,
|
||||
1,
|
||||
"补存成功后应推进 lastProcessedAssistantFloor",
|
||||
);
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().historyState.lastBatchStatus.historyAdvanceAllowed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().historyState.lastBatchStatus.persistence.outcome,
|
||||
"saved",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-b",
|
||||
|
||||
@@ -136,9 +136,15 @@ async function testManualExtractNoBatchesDoesNotStayRunning() {
|
||||
...createBaseStatusContext(),
|
||||
isExtracting: false,
|
||||
currentGraph: {},
|
||||
graphPersistenceState: {
|
||||
pendingPersist: false,
|
||||
},
|
||||
getCurrentChatId() {
|
||||
return "chat-mobile";
|
||||
},
|
||||
getGraphPersistenceState() {
|
||||
return { pendingPersist: false };
|
||||
},
|
||||
ensureGraphMutationReady() {
|
||||
return true;
|
||||
},
|
||||
@@ -173,6 +179,12 @@ async function testManualExtractNoBatchesDoesNotStayRunning() {
|
||||
async executeExtractionBatch() {
|
||||
throw new Error("不应进入批次执行");
|
||||
},
|
||||
async retryPendingGraphPersist() {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: "no-pending-persist",
|
||||
};
|
||||
},
|
||||
isAbortError() {
|
||||
return false;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user