fix: orphan accepted commit marker self-healing — auto-clear stale marker when no recoverable graph source exists

- Add maybeResolveOrphanAcceptedCommitMarker() for conservative orphan detection:
  - Chat-state sidecar rescue first (preserves marker, keeps mismatch diagnostic)
  - Only clears marker when ALL local sources confirmed absent
  - Guards: shadow-available, migration-failed, chat-switched all block clearing
- Enhance clearCurrentChatCommitMarker() with resetAcceptedRevision option
  - Resets lastAcceptedRevision and acceptedStorageTier when marker is orphan
- Integrate orphan resolution into loadGraphFromIndexedDb() empty+mismatch branch
- Update onDeleteCurrentIdbController/onDeleteAllIdbController to resetAcceptedRevision
- Update graph-persistence regressions:
  - Orphan marker scenario now auto-heals to EMPTY_CONFIRMED (was: permanently BLOCKED)
  - Add chat-state rescue guard test: sidecar data prevents marker clearing
This commit is contained in:
Youzini-afk
2026-04-12 20:35:29 +08:00
parent 648a7a1741
commit 5bd29c99d5
3 changed files with 257 additions and 3 deletions

174
index.js
View File

@@ -325,6 +325,7 @@ function clearCurrentChatCommitMarker(
context = getContext(), context = getContext(),
reason = "manual-clear-commit-marker", reason = "manual-clear-commit-marker",
immediate = true, immediate = true,
resetAcceptedRevision = false,
} = {}, } = {},
) { ) {
if (!context) { if (!context) {
@@ -337,15 +338,23 @@ function clearCurrentChatCommitMarker(
} }
const marker = getChatCommitMarker(context); const marker = getChatCommitMarker(context);
const acceptedRevision = getAcceptedCommitMarkerRevision(marker);
writeChatMetadataPatch(context, { writeChatMetadataPatch(context, {
[GRAPH_COMMIT_MARKER_KEY]: null, [GRAPH_COMMIT_MARKER_KEY]: null,
}); });
const saveMode = triggerChatMetadataSave(context, { immediate }); const saveMode = triggerChatMetadataSave(context, { immediate });
const shouldResetAcceptedRevision = resetAcceptedRevision === true;
updateGraphPersistenceState({ updateGraphPersistenceState({
commitMarker: null, commitMarker: null,
persistMismatchReason: "", persistMismatchReason: "",
lastPersistReason: String(reason || "manual-clear-commit-marker"), lastPersistReason: String(reason || "manual-clear-commit-marker"),
lastPersistMode: `commit-marker-clear:${saveMode}`, lastPersistMode: `commit-marker-clear:${saveMode}`,
acceptedStorageTier: shouldResetAcceptedRevision
? "none"
: String(graphPersistenceState.acceptedStorageTier || "none"),
lastAcceptedRevision: shouldResetAcceptedRevision
? 0
: Number(graphPersistenceState.lastAcceptedRevision || 0),
}); });
return { return {
@@ -353,6 +362,7 @@ function clearCurrentChatCommitMarker(
reason: String(reason || "manual-clear-commit-marker"), reason: String(reason || "manual-clear-commit-marker"),
saveMode, saveMode,
marker: cloneRuntimeDebugValue(marker, null), 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( function applyIndexedDbSnapshotToRuntime(
chatId, chatId,
snapshot, snapshot,
@@ -6078,6 +6230,28 @@ async function loadGraphFromIndexedDb(
return shadowRestoreResult; 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 ( if (
applyEmptyState && applyEmptyState &&
!commitMarkerDiagnostic?.reason && !commitMarkerDiagnostic?.reason &&

View File

@@ -2187,11 +2187,89 @@ result = {
assert.equal(result.loadState, "loading"); assert.equal(result.loadState, "loading");
assert.equal( assert.equal(
harness.api.getGraphPersistenceState().loadState, harness.api.getGraphPersistenceState().loadState,
"blocked", "empty-confirmed",
"IndexedDB 空快照但 accepted commit marker 更高时,重试耗尽后不应永久停留在 loading", " accepted commit marker 已成孤儿且本地不存在可恢复图谱源时,应自动降级为 empty-confirmed",
);
assert.match(
String(harness.api.getGraphPersistenceState().reason || ""),
/orphan-accepted-marker/,
); );
assert.equal( 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", "persist-mismatch:indexeddb-behind-commit-marker",
); );
} }

View File

@@ -1196,6 +1196,7 @@ export async function onDeleteCurrentIdbController(runtime) {
runtime.clearCurrentChatCommitMarker?.({ runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-current-idb", reason: "manual-delete-current-idb",
immediate: true, immediate: true,
resetAcceptedRevision: true,
}); });
runtime.syncGraphLoadFromLiveContext?.({ runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-current-idb", source: "manual-delete-current-idb",
@@ -1252,6 +1253,7 @@ export async function onDeleteAllIdbController(runtime) {
runtime.clearCurrentChatCommitMarker?.({ runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-all-idb", reason: "manual-delete-all-idb",
immediate: true, immediate: true,
resetAcceptedRevision: true,
}); });
runtime.syncGraphLoadFromLiveContext?.({ runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-all-idb", source: "manual-delete-all-idb",