Fix pending persistence confirmation deadlocks

This commit is contained in:
Youzini-afk
2026-04-10 01:46:41 +08:00
parent e8a4568bfd
commit f88a6cc0af
4 changed files with 651 additions and 19 deletions

494
index.js
View File

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

View File

@@ -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(
"等待持久化确认",

View File

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

View File

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