Harden graph recovery and shadow persistence

This commit is contained in:
Youzini-afk
2026-04-07 17:18:18 +08:00
parent aa1d194c28
commit 854e3a7a79
11 changed files with 713 additions and 20 deletions

View File

@@ -87,6 +87,34 @@ assert.deepEqual(
"extraction should keep BME-managed hidden context but still skip real system messages",
);
const blankAssistantChat = [
{ is_user: false, is_system: true, mes: "greeting/system" },
{ is_user: true, is_system: false, mes: "user-1" },
{ is_user: false, is_system: false, mes: " " },
{ is_user: true, is_system: false, mes: "<plot>secret</plot>" },
{ is_user: false, is_system: false, mes: "assistant-2" },
];
assert.deepEqual(
getAssistantTurns(blankAssistantChat),
[4],
"blank assistant floors should not be treated as extractable turns",
);
assert.deepEqual(
buildExtractionMessages(blankAssistantChat, 4, 4, {
extractContextTurns: 3,
}).map((message) => ({
seq: message.seq,
role: message.role,
content: message.content,
})),
[
{ seq: 1, role: "user", content: "user-1" },
{ seq: 4, role: "assistant", content: "assistant-2" },
],
"blank assistant text and planner-tag-only user text should be skipped",
);
resetHideState();
const autoHiddenChat = [
{ is_user: false, is_system: true, mes: "greeting/system" },

View File

@@ -1641,13 +1641,16 @@ result = {
source: "shadow-test",
});
assert.equal(result.loadState, "loading");
assert.equal(reader.api.getCurrentGraph(), null);
assert.equal(result.loadState, "shadow-restored");
assert.equal(
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
"事件-shadow",
);
assert.equal(
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
false,
true,
);
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, true);
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false);
}
{
@@ -1949,7 +1952,7 @@ result = {
});
const live = reader.api.getGraphPersistenceLiveState();
assert.equal(result.loadState, "loading");
assert.equal(result.loadState, "shadow-restored");
assert.equal(
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
?.length,
@@ -1961,8 +1964,8 @@ result = {
);
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
assert.equal(live.lastPersistedRevision, 0);
assert.equal(live.pendingPersist, false);
assert.equal(live.lastPersistedRevision, 9);
assert.equal(live.pendingPersist, true);
}
{
@@ -2102,7 +2105,7 @@ result = {
source: "load-shadow-decoupled",
});
assert.equal(result.loadState, "loading");
assert.equal(result.loadState, "shadow-restored");
const runtimeGraph = reader.api.getCurrentGraph();
const persistedGraph =
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
@@ -2113,6 +2116,10 @@ result = {
);
runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated";
assert.equal(
runtimeGraph.nodes[0].fields.title,
"runtime-shadow-mutated",
);
assert.equal(
persistedGraph.nodes[0].fields.title,
"事件-official-older",
@@ -2355,6 +2362,65 @@ result = {
);
}
{
const sharedSession = new Map();
const writer = await createGraphPersistenceHarness({
chatId: "chat-indexeddb-shadow-restore",
globalChatId: "chat-indexeddb-shadow-restore",
sessionStore: sharedSession,
});
writer.api.writeGraphShadowSnapshot(
"chat-indexeddb-shadow-restore",
createMeaningfulGraph("chat-indexeddb-shadow-restore", "shadow-newer"),
{
revision: 9,
reason: "pagehide-refresh",
},
);
const indexedDbGraph = stampPersistedGraph(
createMeaningfulGraph("chat-indexeddb-shadow-restore", "indexeddb-older"),
{
revision: 4,
integrity: "meta-indexeddb-shadow-restore",
chatId: "chat-indexeddb-shadow-restore",
reason: "indexeddb-older",
},
);
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
chatId: "chat-indexeddb-shadow-restore",
revision: 4,
});
const harness = await createGraphPersistenceHarness({
chatId: "chat-indexeddb-shadow-restore",
globalChatId: "chat-indexeddb-shadow-restore",
indexedDbSnapshot,
sessionStore: sharedSession,
});
const result = await harness.api.loadGraphFromIndexedDb(
"chat-indexeddb-shadow-restore",
{
source: "indexeddb-shadow-restore",
allowOverride: true,
applyEmptyState: true,
},
);
assert.equal(result.loadState, "shadow-restored");
assert.equal(
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
"事件-shadow-newer",
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
harness.api.getIndexedDbSnapshot().meta.revision,
9,
"shadow 恢复后应回补 IndexedDB 修正旧快照",
);
}
{
const legacyGraph = stampPersistedGraph(
createMeaningfulGraph("chat-legacy-migration", "legacy"),

View File

@@ -140,6 +140,10 @@ export function createGenerationRecallHarness(options = {}) {
recordedInjectionSnapshots: [],
refreshPanelCalls: 0,
hideScheduleCalls: [],
isExtracting: false,
isRecoveringHistory: false,
isAssistantChatMessage: (message) =>
Boolean(message) && !message.is_user && !message.is_system,
createRecallInputRecord,
createRecallRunResult,
hashRecallInput,
@@ -215,7 +219,7 @@ export function createGenerationRecallHarness(options = {}) {
};
vm.createContext(context);
vm.runInContext(
`${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
`${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
context,
{ filename: indexPath },
);
@@ -322,9 +326,12 @@ export function createGenerationRecallHarness(options = {}) {
consumeCurrentGenerationTrivialSkip:
context.result.consumeCurrentGenerationTrivialSkip,
createRecallInputRecord,
deferAutoExtraction: context.result.deferAutoExtraction,
getContext: context.getContext,
getCurrentGraph: () => context.currentGraph,
getGraphPersistenceState: () => context.result.getGraphPersistenceState(),
getIsHostGenerationRunning: () =>
context.result.getIsHostGenerationRunning(),
getPendingHostGenerationInputSnapshot:
context.result.getPendingHostGenerationInputSnapshot,
getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(),

View File

@@ -3716,6 +3716,89 @@ async function testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask()
assert.equal(refreshCalls, 1);
}
async function testMessageReceivedDefersExtractionDuringHostGeneration() {
let runExtractionCalls = 0;
const deferred = [];
onMessageReceivedController(
{
getGraphPersistenceState: () => ({ loadState: "loaded", dbReady: true }),
getCurrentGraph: () => null,
getPendingRecallSendIntent: () => ({ text: "", at: 0 }),
getIsHostGenerationRunning: () => true,
isFreshRecallInputRecord: () => true,
createRecallInputRecord: () => ({ text: "", at: 0 }),
deferAutoExtraction(reason, meta = {}) {
deferred.push({
reason,
messageId: Number.isFinite(Number(meta?.messageId))
? Number(meta.messageId)
: null,
});
},
setPendingRecallSendIntent() {},
getContext: () => ({
chat: [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
],
}),
isAssistantChatMessage(message) {
return Boolean(message) && !message.is_user && !message.is_system;
},
runExtraction: async () => {
runExtractionCalls += 1;
},
console: {
error() {},
},
notifyExtractionIssue() {},
refreshPersistedRecallMessageUi() {},
},
1,
"assistant",
);
await waitForTick();
assert.equal(runExtractionCalls, 0);
assert.deepEqual(deferred, [
{
reason: "generation-running",
messageId: 1,
},
]);
}
async function testGenerationEndedResumesPendingAutoExtractionAfterSettle() {
const harness = await createGenerationRecallHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "streaming response" },
];
harness.result.setGraphPersistenceState({
loadState: "loaded",
dbReady: true,
chatId: "chat-main",
});
harness.result.onGenerationStarted("normal", {}, false);
harness.invokeOnMessageReceived(1, "assistant");
await waitForTick();
assert.equal(harness.runExtractionCalls.length, 0);
assert.equal(
harness.result.getPendingAutoExtraction().reason,
"generation-running",
);
harness.result.onGenerationEnded();
await new Promise((resolve) => setTimeout(resolve, 180));
assert.equal(harness.runExtractionCalls.length, 1);
harness.result.clearPendingAutoExtraction();
}
async function testAutoExtractionDefersWhenGraphNotReady() {
const deferredReasons = [];
const statuses = [];
@@ -5671,6 +5754,8 @@ await testMessageSentFallsBackToLatestUserWhenHostMessageIdInvalid();
await testUserMessageRenderedRefreshesRecallUiAfterRealDomRender();
await testCharacterMessageRenderedRefreshesRecallUiAfterAssistantRender();
await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask();
await testMessageReceivedDefersExtractionDuringHostGeneration();
await testGenerationEndedResumesPendingAutoExtractionAfterSettle();
await testAutoExtractionDefersWhenGraphNotReady();
await testAutoExtractionDefersWhenAlreadyExtracting();
await testAutoExtractionDefersWhenHistoryRecoveryBusy();

View File

@@ -7,6 +7,7 @@ import {
findJournalRecoveryPoint,
normalizeGraphRuntimeState,
PROCESSED_MESSAGE_HASH_VERSION,
rebindProcessedHistoryStateToChat,
rollbackBatch,
snapshotProcessedMessageHashes,
} from "../runtime-state.js";
@@ -94,6 +95,28 @@ assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true)
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
assert.equal(migratedDetection.dirty, false);
const importedGraph = normalizeGraphRuntimeState({
historyState: {
chatId: "chat-history-test",
lastProcessedAssistantFloor: 99,
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
processedMessageHashes: {},
processedMessageHashesNeedRefresh: true,
},
});
const reboundResult = rebindProcessedHistoryStateToChat(importedGraph, chat, [
1,
3,
]);
assert.equal(reboundResult.rebound, true);
assert.equal(reboundResult.lastProcessedAssistantFloor, 3);
assert.equal(reboundResult.clamped, true);
assert.equal(importedGraph.historyState.processedMessageHashesNeedRefresh, false);
assert.deepEqual(
importedGraph.historyState.processedMessageHashes,
snapshotProcessedMessageHashes(chat, 3),
);
const truncatedChat = chat.slice(0, 2);
const truncatedDetection = detectHistoryMutation(truncatedChat, {
lastProcessedAssistantFloor: 3,