mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
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:
174
index.js
174
index.js
@@ -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 &&
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user