diff --git a/index.js b/index.js index 9aebc59..88043ba 100644 --- a/index.js +++ b/index.js @@ -325,6 +325,7 @@ function clearCurrentChatCommitMarker( context = getContext(), reason = "manual-clear-commit-marker", immediate = true, + resetAcceptedRevision = false, } = {}, ) { if (!context) { @@ -337,15 +338,23 @@ function clearCurrentChatCommitMarker( } const marker = getChatCommitMarker(context); + const acceptedRevision = getAcceptedCommitMarkerRevision(marker); writeChatMetadataPatch(context, { [GRAPH_COMMIT_MARKER_KEY]: null, }); const saveMode = triggerChatMetadataSave(context, { immediate }); + const shouldResetAcceptedRevision = resetAcceptedRevision === true; updateGraphPersistenceState({ commitMarker: null, persistMismatchReason: "", lastPersistReason: String(reason || "manual-clear-commit-marker"), lastPersistMode: `commit-marker-clear:${saveMode}`, + acceptedStorageTier: shouldResetAcceptedRevision + ? "none" + : String(graphPersistenceState.acceptedStorageTier || "none"), + lastAcceptedRevision: shouldResetAcceptedRevision + ? 0 + : Number(graphPersistenceState.lastAcceptedRevision || 0), }); return { @@ -353,6 +362,7 @@ function clearCurrentChatCommitMarker( reason: String(reason || "manual-clear-commit-marker"), saveMode, marker: cloneRuntimeDebugValue(marker, null), + acceptedRevision, }; } @@ -5663,6 +5673,148 @@ function applyIndexedDbEmptyToRuntime( }; } +async function maybeResolveOrphanAcceptedCommitMarker( + chatId, + { + source = "indexeddb-probe", + attemptIndex = 0, + commitMarker = null, + migrationResult = null, + shadowSnapshot = null, + applyEmptyState = false, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + const context = getContext(); + const activeIdentity = resolveCurrentChatIdentity(context); + const activePersistenceChatId = + normalizeChatIdCandidate(activeIdentity?.chatId) || normalizedChatId; + const acceptedRevision = getAcceptedCommitMarkerRevision(commitMarker); + if (!normalizedChatId || acceptedRevision <= 0) { + return { + resolved: false, + reason: "marker-not-accepted", + result: null, + chatId: normalizedChatId || "", + }; + } + + if (!doesChatIdMatchResolvedGraphIdentity(normalizedChatId, activeIdentity)) { + return { + resolved: false, + reason: "chat-switched", + result: null, + chatId: normalizedChatId, + }; + } + + let chatStateResult = null; + if (canUseHostGraphChatStatePersistence(context)) { + chatStateResult = await loadGraphFromChatState(activePersistenceChatId, { + source: `${source}:orphan-chat-state-fallback`, + attemptIndex, + allowOverride: true, + }); + if (chatStateResult?.loaded) { + return { + resolved: true, + reason: "chat-state-loaded", + result: chatStateResult, + chatId: normalizedChatId, + chatStateResult, + orphanCleared: false, + }; + } + + const chatStateReason = String(chatStateResult?.reason || ""); + if ( + chatStateReason && + chatStateReason !== "chat-state-empty" && + chatStateReason !== "chat-state-unavailable" + ) { + return { + resolved: false, + reason: chatStateReason, + result: null, + chatId: normalizedChatId, + chatStateResult, + }; + } + } + + if (shadowSnapshot) { + return { + resolved: false, + reason: "shadow-available", + result: null, + chatId: normalizedChatId, + chatStateResult, + }; + } + + if (String(migrationResult?.reason || "").trim() === "migration-failed") { + return { + resolved: false, + reason: "migration-failed", + result: null, + chatId: normalizedChatId, + chatStateResult, + }; + } + + const clearResult = clearCurrentChatCommitMarker({ + context, + reason: `orphan-accepted-marker:${source}`, + immediate: true, + resetAcceptedRevision: true, + }); + debugDebug("[ST-BME] 已自动清理孤儿 accepted commit marker", { + chatId: normalizedChatId, + source, + acceptedRevision, + migrationReason: String(migrationResult?.reason || ""), + chatStateReason: String(chatStateResult?.reason || ""), + }); + + if (applyEmptyState) { + const emptyResult = applyIndexedDbEmptyToRuntime(activePersistenceChatId, { + source: `${source}:orphan-accepted-marker`, + attemptIndex, + }); + return { + resolved: true, + reason: "orphan-accepted-marker-cleared", + result: { + ...emptyResult, + orphanCommitMarkerCleared: true, + clearedMarkerRevision: acceptedRevision, + }, + chatId: normalizedChatId, + chatStateResult, + clearResult, + orphanCleared: true, + }; + } + + return { + resolved: true, + reason: "orphan-accepted-marker-cleared", + result: { + success: false, + loaded: false, + reason: "indexeddb-empty", + chatId: normalizedChatId, + attemptIndex, + orphanCommitMarkerCleared: true, + clearedMarkerRevision: acceptedRevision, + }, + chatId: normalizedChatId, + chatStateResult, + clearResult, + orphanCleared: true, + }; +} + function applyIndexedDbSnapshotToRuntime( chatId, snapshot, @@ -6078,6 +6230,28 @@ async function loadGraphFromIndexedDb( return shadowRestoreResult; } } + if (commitMarkerDiagnostic?.reason) { + const orphanMarkerResolution = + await maybeResolveOrphanAcceptedCommitMarker(normalizedChatId, { + source, + attemptIndex, + commitMarker, + migrationResult, + shadowSnapshot, + applyEmptyState, + }); + if (orphanMarkerResolution?.result) { + if ( + !orphanMarkerResolution.orphanCleared && + orphanMarkerResolution.result?.loaded + ) { + updateGraphPersistenceState({ + persistMismatchReason: commitMarkerDiagnostic.reason, + }); + } + return orphanMarkerResolution.result; + } + } if ( applyEmptyState && !commitMarkerDiagnostic?.reason && diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 1b25523..3969a4c 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -2187,11 +2187,89 @@ result = { assert.equal(result.loadState, "loading"); assert.equal( harness.api.getGraphPersistenceState().loadState, - "blocked", - "IndexedDB 空快照但 accepted commit marker 更高时,重试耗尽后不应永久停留在 loading", + "empty-confirmed", + "当 accepted commit marker 已成孤儿且本地不存在可恢复图谱源时,应自动降级为 empty-confirmed", + ); + assert.match( + String(harness.api.getGraphPersistenceState().reason || ""), + /orphan-accepted-marker/, ); assert.equal( - harness.api.getGraphPersistenceState().reason, + harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY], + null, + ); + assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 1); + assert.equal(harness.api.getGraphPersistenceState().lastAcceptedRevision, 0); + assert.equal(harness.api.getGraphPersistenceState().commitMarker, null); + } + + { + const commitMarker = buildGraphCommitMarker( + createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "marker"), + { + revision: 8, + storageTier: "indexeddb", + accepted: true, + reason: "test-chat-state-rescue", + chatId: "chat-indexeddb-empty-chat-state-rescue", + integrity: "meta-indexeddb-empty-chat-state-rescue", + }, + ); + const harness = await createGraphPersistenceHarness({ + chatId: "chat-indexeddb-empty-chat-state-rescue", + globalChatId: "chat-indexeddb-empty-chat-state-rescue", + chatMetadata: { + integrity: "meta-indexeddb-empty-chat-state-rescue", + [GRAPH_COMMIT_MARKER_KEY]: commitMarker, + }, + }); + const sidecarGraph = stampPersistedGraph( + createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "sidecar"), + { + revision: 8, + integrity: "meta-indexeddb-empty-chat-state-rescue", + chatId: "chat-indexeddb-empty-chat-state-rescue", + reason: "sidecar-rescue-seed", + }, + ); + harness.runtimeContext.__chatContext.__chatStateStore.set( + GRAPH_CHAT_STATE_NAMESPACE, + buildGraphChatStateSnapshot(sidecarGraph, { + revision: 8, + storageTier: "chat-state", + accepted: true, + reason: "sidecar-rescue-seed", + chatId: "chat-indexeddb-empty-chat-state-rescue", + integrity: "meta-indexeddb-empty-chat-state-rescue", + lastProcessedAssistantFloor: 6, + extractionCount: 3, + }), + ); + + const result = await harness.api.loadGraphFromIndexedDb( + "chat-indexeddb-empty-chat-state-rescue", + { + source: "indexeddb-empty-chat-state-rescue", + attemptIndex: 0, + allowOverride: true, + applyEmptyState: true, + }, + ); + + assert.equal(result.loaded, true); + assert.equal(result.loadState, "loaded"); + assert.equal( + harness.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-sidecar", + ); + assert.equal( + harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY] + ?.revision, + 8, + ); + assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0); + assert.equal( + harness.api.getGraphPersistenceState().persistMismatchReason, "persist-mismatch:indexeddb-behind-commit-marker", ); } diff --git a/ui/ui-actions-controller.js b/ui/ui-actions-controller.js index e5d199d..7021da4 100644 --- a/ui/ui-actions-controller.js +++ b/ui/ui-actions-controller.js @@ -1196,6 +1196,7 @@ export async function onDeleteCurrentIdbController(runtime) { runtime.clearCurrentChatCommitMarker?.({ reason: "manual-delete-current-idb", immediate: true, + resetAcceptedRevision: true, }); runtime.syncGraphLoadFromLiveContext?.({ source: "manual-delete-current-idb", @@ -1252,6 +1253,7 @@ export async function onDeleteAllIdbController(runtime) { runtime.clearCurrentChatCommitMarker?.({ reason: "manual-delete-all-idb", immediate: true, + resetAcceptedRevision: true, }); runtime.syncGraphLoadFromLiveContext?.({ source: "manual-delete-all-idb",