fix: harden history recovery and graph persistence regressions

This commit is contained in:
Youzini-afk
2026-03-31 22:48:48 +08:00
parent 1098c33a93
commit 7d71d1015e
5 changed files with 669 additions and 33 deletions

View File

@@ -242,6 +242,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
return { return {
prefer: false, prefer: false,
reason: "shadow-missing", reason: "shadow-missing",
resultCode: "shadow.missing",
}; };
} }
@@ -260,6 +261,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
return { return {
prefer: false, prefer: false,
reason: "shadow-revision-invalid", reason: "shadow-revision-invalid",
resultCode: "shadow.reject.revision-invalid",
shadowRevision, shadowRevision,
officialRevision, officialRevision,
}; };
@@ -273,6 +275,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
return { return {
prefer: false, prefer: false,
reason: "shadow-persisted-chat-mismatch", reason: "shadow-persisted-chat-mismatch",
resultCode: "shadow.reject.persisted-chat-mismatch",
shadowRevision, shadowRevision,
officialRevision, officialRevision,
officialChatId: normalizedOfficialChatId, officialChatId: normalizedOfficialChatId,
@@ -288,6 +291,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
return { return {
prefer: false, prefer: false,
reason: "shadow-chat-mismatch", reason: "shadow-chat-mismatch",
resultCode: "shadow.reject.chat-mismatch",
shadowRevision, shadowRevision,
officialRevision, officialRevision,
officialChatId: normalizedOfficialChatId, officialChatId: normalizedOfficialChatId,
@@ -303,6 +307,7 @@ export function shouldPreferShadowSnapshotOverOfficial(
return { return {
prefer: false, prefer: false,
reason: "shadow-integrity-mismatch", reason: "shadow-integrity-mismatch",
resultCode: "shadow.reject.integrity-mismatch",
shadowRevision, shadowRevision,
officialRevision, officialRevision,
officialIntegrity, officialIntegrity,
@@ -310,12 +315,54 @@ export function shouldPreferShadowSnapshotOverOfficial(
}; };
} }
if (
normalizedShadowPersistedChatId &&
normalizedShadowChatId &&
normalizedShadowPersistedChatId !== normalizedShadowChatId
) {
return {
prefer: false,
reason: "shadow-self-chat-mismatch",
resultCode: "shadow.reject.self-chat-mismatch",
shadowRevision,
officialRevision,
shadowChatId: normalizedShadowChatId,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) {
return {
prefer: false,
reason: "shadow-persisted-chat-without-official-chat",
resultCode: "shadow.reject.persisted-chat-without-official-chat",
shadowRevision,
officialRevision,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (shadowIntegrity && !officialIntegrity) {
return {
prefer: false,
reason: "shadow-integrity-without-official-integrity",
resultCode: "shadow.reject.integrity-without-official-integrity",
shadowRevision,
officialRevision,
shadowIntegrity,
};
}
return { return {
prefer: shadowRevision > 0 && shadowRevision > officialRevision, prefer: shadowRevision > 0 && shadowRevision > officialRevision,
reason: reason:
shadowRevision > officialRevision shadowRevision > officialRevision
? "shadow-newer-than-official" ? "shadow-newer-than-official"
: "shadow-not-newer-than-official", : "shadow-not-newer-than-official",
resultCode:
shadowRevision > officialRevision
? "shadow.accept.newer-than-official"
: "shadow.keep.official-not-older",
shadowRevision, shadowRevision,
officialRevision, officialRevision,
}; };

142
index.js
View File

@@ -2790,6 +2790,7 @@ function applyIndexedDbSnapshotToRuntime(
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
normalizedChatId, normalizedChatId,
); );
currentGraph.vectorIndexState.lastIntegrityIssue = null;
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
? currentGraph.historyState.extractionCount ? currentGraph.historyState.extractionCount
@@ -4373,6 +4374,7 @@ function loadGraphFromChat(options = {}) {
source: `${source}:metadata-shadow-compare`, source: `${source}:metadata-shadow-compare`,
success: Boolean(shadowDecision.prefer), success: Boolean(shadowDecision.prefer),
reason: shadowDecision.reason, reason: shadowDecision.reason,
resultCode: String(shadowDecision.resultCode || ""),
shadowRevision: Number(shadowSnapshot.revision || 0), shadowRevision: Number(shadowSnapshot.revision || 0),
officialRevision, officialRevision,
at: Date.now(), at: Date.now(),
@@ -4441,6 +4443,8 @@ function loadGraphFromChat(options = {}) {
success: true, success: true,
provisional: true, provisional: true,
revision: officialRevision, revision: officialRevision,
resultCode: "graph.load.metadata-compat.provisional",
reason: `${source}:metadata-compat-provisional`,
at: Date.now(), at: Date.now(),
}, },
}); });
@@ -6142,20 +6146,31 @@ function applyRecoveryPlanToVectorState(
async function rollbackGraphForReroll(targetFloor, context = getContext()) { async function rollbackGraphForReroll(targetFloor, context = getContext()) {
ensureCurrentGraphRuntimeState(); ensureCurrentGraphRuntimeState();
const chatId = getCurrentChatId(context); const chatId = getCurrentChatId(context);
const buildRerollFailure = (
recoveryPath,
error,
{ resultCode = "reroll.rollback.failed", affectedBatchCount = 0 } = {},
) => ({
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: targetFloor,
effectiveFromFloor: null,
recoveryPath,
affectedBatchCount,
resultCode,
error,
});
const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor); const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor);
if (!recoveryPoint) { if (!recoveryPoint) {
return { return buildRerollFailure(
success: false, "unavailable",
rollbackPerformed: false, "未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。",
extractionTriggered: false, {
requestedFloor: targetFloor, resultCode: "reroll.rollback.unavailable",
effectiveFromFloor: null, },
recoveryPath: "unavailable", );
affectedBatchCount: 0,
error:
"未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。",
};
} }
clearInjectionState(); clearInjectionState();
@@ -6171,16 +6186,33 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
targetFloor, targetFloor,
); );
if (recoveryPlan?.valid === false) { if (recoveryPlan?.valid === false) {
return { const invalidReason = String(
success: false, recoveryPlan.invalidReason || "unknown",
rollbackPerformed: false, ).trim();
extractionTriggered: false, currentGraph.historyState.lastRecoveryResult = buildRecoveryResult(
requestedFloor: targetFloor, "reroll-rollback-rejected",
effectiveFromFloor: null, {
recoveryPath: "reverse-journal-rejected", fromFloor: targetFloor,
affectedBatchCount, effectiveFromFloor: null,
error: `回滚计划完整性校验失败: ${recoveryPlan.invalidReason || "unknown"}`, path: "reverse-journal",
}; affectedBatchCount,
detectionSource: "manual-reroll",
reason: `回滚计划完整性校验失败: ${invalidReason}`,
debugReason: `reroll-rollback-plan-invalid:${invalidReason}`,
resultCode: "reroll.rollback.plan-invalid",
invalidReason,
},
);
saveGraphToChat({ reason: "reroll-rollback-rejected" });
refreshPanelLiveState();
return buildRerollFailure(
"reverse-journal-rejected",
`回滚计划完整性校验失败: ${invalidReason}`,
{
affectedBatchCount,
resultCode: "reroll.rollback.plan-invalid",
},
);
} }
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals); rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId); currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
@@ -6211,16 +6243,29 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
extractionCount = currentGraph.historyState.extractionCount || 0; extractionCount = currentGraph.historyState.extractionCount || 0;
await prepareVectorStateForReplay(false); await prepareVectorStateForReplay(false);
} else { } else {
return { currentGraph.historyState.lastRecoveryResult = buildRecoveryResult(
success: false, "reroll-rollback-rejected",
rollbackPerformed: false, {
extractionTriggered: false, fromFloor: targetFloor,
requestedFloor: targetFloor, effectiveFromFloor: null,
effectiveFromFloor: null, path: recoveryPath,
affectedBatchCount,
detectionSource: "manual-reroll",
reason: `不支持的回滚路径: ${recoveryPath}`,
debugReason: `reroll-rollback-unsupported:${recoveryPath}`,
resultCode: "reroll.rollback.path-unsupported",
},
);
saveGraphToChat({ reason: "reroll-rollback-rejected" });
refreshPanelLiveState();
return buildRerollFailure(
recoveryPath, recoveryPath,
affectedBatchCount, `不支持的回滚路径: ${recoveryPath}`,
error: `不支持的回滚路径: ${recoveryPath}`, {
}; affectedBatchCount,
resultCode: "reroll.rollback.path-unsupported",
},
);
} }
const effectiveFromFloor = Number.isFinite( const effectiveFromFloor = Number.isFinite(
@@ -6229,9 +6274,6 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
? currentGraph.historyState.lastProcessedAssistantFloor + 1 ? currentGraph.historyState.lastProcessedAssistantFloor + 1
: 0; : 0;
pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor);
currentGraph.lastProcessedSeq =
currentGraph.historyState?.lastProcessedAssistantFloor ?? -1;
clearHistoryDirty( clearHistoryDirty(
currentGraph, currentGraph,
buildRecoveryResult("reroll-rollback", { buildRecoveryResult("reroll-rollback", {
@@ -6241,8 +6283,13 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
affectedBatchCount, affectedBatchCount,
detectionSource: "manual-reroll", detectionSource: "manual-reroll",
reason: "manual-reroll", reason: "manual-reroll",
resultCode: "reroll.rollback.applied",
}), }),
); );
pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor);
currentGraph.lastProcessedSeq =
currentGraph.historyState?.lastProcessedAssistantFloor ?? -1;
currentGraph.vectorIndexState.lastIntegrityIssue = null;
saveGraphToChat({ reason: "reroll-rollback-complete" }); saveGraphToChat({ reason: "reroll-rollback-complete" });
refreshPanelLiveState(); refreshPanelLiveState();
@@ -6254,6 +6301,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
effectiveFromFloor, effectiveFromFloor,
recoveryPath, recoveryPath,
affectedBatchCount, affectedBatchCount,
resultCode: "reroll.rollback.applied",
error: "", error: "",
}; };
} }
@@ -6408,6 +6456,28 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
return true; return true;
} catch (error) { } catch (error) {
if (isAbortError(error)) { if (isAbortError(error)) {
clearHistoryDirty(
currentGraph,
buildRecoveryResult("aborted", {
fromFloor: initialDirtyFrom,
path: recoveryPath,
detectionSource:
detection.source ||
currentGraph?.historyState?.lastMutationSource ||
"hash-recheck",
affectedBatchCount,
replayedBatchCount: replayedBatches,
reason: error?.message || "已手动终止当前恢复流程",
debugReason: `history-recovery-aborted:${recoveryPath}`,
resultCode: "history.recovery.aborted",
}),
);
currentGraph.vectorIndexState.lastIntegrityIssue = null;
currentGraph.vectorIndexState.lastWarning = "";
currentGraph.vectorIndexState.pendingRepairFromFloor = null;
currentGraph.vectorIndexState.replayRequiredNodeIds = [];
currentGraph.vectorIndexState.dirty = false;
currentGraph.vectorIndexState.dirtyReason = "";
updateStageNotice( updateStageNotice(
"history", "history",
"历史恢复已终止", "历史恢复已终止",
@@ -6447,8 +6517,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
affectedBatchCount, affectedBatchCount,
replayedBatchCount: replayedBatches, replayedBatchCount: replayedBatches,
reason: `恢复失败后兜底全量重建: ${error?.message || error}`, reason: `恢复失败后兜底全量重建: ${error?.message || error}`,
debugReason: `history-recovery-fallback-full-rebuild:${recoveryPath}`,
resultCode: "history.recovery.fallback-full-rebuild",
}), }),
); );
currentGraph.vectorIndexState.lastIntegrityIssue = null;
saveGraphToChat({ reason: "history-recovery-fallback-rebuild" }); saveGraphToChat({ reason: "history-recovery-fallback-rebuild" });
refreshPanelLiveState(); refreshPanelLiveState();
updateStageNotice( updateStageNotice(
@@ -6476,8 +6549,11 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
affectedBatchCount, affectedBatchCount,
replayedBatchCount: replayedBatches, replayedBatchCount: replayedBatches,
reason: String(fallbackError), reason: String(fallbackError),
debugReason: `history-recovery-failed:${recoveryPath}`,
resultCode: "history.recovery.failed",
}, },
); );
currentGraph.vectorIndexState.lastIntegrityIssue = null;
saveGraphToChat({ reason: "history-recovery-failed" }); saveGraphToChat({ reason: "history-recovery-failed" });
refreshPanelLiveState(); refreshPanelLiveState();
updateStageNotice( updateStageNotice(

View File

@@ -324,6 +324,7 @@ export function clearHistoryDirty(graph, result = null) {
graph.historyState.historyDirtyFrom = null; graph.historyState.historyDirtyFrom = null;
graph.historyState.lastMutationReason = ""; graph.historyState.lastMutationReason = "";
graph.historyState.lastMutationSource = ""; graph.historyState.lastMutationSource = "";
graph.historyState.processedMessageHashes = {};
if (result) { if (result) {
graph.historyState.lastRecoveryResult = result; graph.historyState.lastRecoveryResult = result;
} }

View File

@@ -591,6 +591,18 @@ result = {
); );
assert.equal(harness.api.getGraphPersistenceState().dbReady, false); assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true); assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
assert.equal(
harness.api.getGraphPersistenceState().dualWriteLastResult?.resultCode,
"graph.load.metadata-compat.provisional",
);
assert.equal(
harness.api.getGraphPersistenceState().dualWriteLastResult?.provisional,
true,
);
assert.equal(
harness.api.getGraphPersistenceState().dualWriteLastResult?.reason,
"global-chat-id:metadata-compat-provisional",
);
} }
{ {
@@ -1158,6 +1170,77 @@ result = {
const live = reader.api.getGraphPersistenceLiveState(); const live = reader.api.getGraphPersistenceLiveState();
assert.equal(live.shadowSnapshotRevision, 9); assert.equal(live.shadowSnapshotRevision, 9);
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch"); assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
const compareDecision = shouldPreferShadowSnapshotOverOfficial(
officialGraph,
reader.api.readGraphShadowSnapshot("chat-shadow-newer"),
);
assert.equal(compareDecision.resultCode, "shadow.reject.integrity-mismatch");
}
{
const decision = shouldPreferShadowSnapshotOverOfficial(
stampPersistedGraph(createMeaningfulGraph("chat-self-mismatch"), {
revision: 0,
chatId: "",
integrity: "",
}),
{
chatId: "chat-self-mismatch",
persistedChatId: "chat-other",
revision: 5,
integrity: "",
},
);
assert.equal(decision.prefer, false);
assert.equal(decision.reason, "shadow-self-chat-mismatch");
assert.equal(decision.resultCode, "shadow.reject.self-chat-mismatch");
}
{
const decision = shouldPreferShadowSnapshotOverOfficial(
stampPersistedGraph(createMeaningfulGraph("chat-official-missing"), {
revision: 0,
chatId: "",
integrity: "",
}),
{
chatId: "chat-official-missing",
persistedChatId: "chat-official-missing",
revision: 4,
integrity: "",
},
);
assert.equal(decision.prefer, false);
assert.equal(decision.reason, "shadow-persisted-chat-without-official-chat");
assert.equal(
decision.resultCode,
"shadow.reject.persisted-chat-without-official-chat",
);
}
{
const decision = shouldPreferShadowSnapshotOverOfficial(
stampPersistedGraph(
createMeaningfulGraph("chat-official-integrity-missing"),
{
revision: 0,
chatId: "chat-official-integrity-missing",
integrity: "",
},
),
{
chatId: "chat-official-integrity-missing",
persistedChatId: "chat-official-integrity-missing",
revision: 4,
integrity: "shadow-only-integrity",
},
);
assert.equal(decision.prefer, false);
assert.equal(decision.reason, "shadow-integrity-without-official-integrity");
assert.equal(
decision.resultCode,
"shadow.reject.integrity-without-official-integrity",
);
} }
{ {

View File

@@ -368,6 +368,185 @@ function createGenerationRecallHarness() {
}); });
} }
function createHistoryRecoveryHarness() {
return fs.readFile(indexPath, "utf8").then((source) => {
const start = source.indexOf("async function recoverHistoryIfNeeded(");
const end = source.indexOf("/**\n * 提取管线:处理未提取的对话楼层");
if (start < 0 || end < 0 || end <= start) {
throw new Error("无法从 index.js 提取 history recovery 定义");
}
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
const context = {
console,
Date,
result: null,
currentGraph: null,
extractionCount: 0,
isRecoveringHistory: false,
chat: [],
clearedHistoryDirty: null,
prepareVectorStateCalls: [],
saveGraphToChatCalls: 0,
refreshPanelCalls: 0,
notices: [],
embeddingConfig: { mode: "backend" },
ensureCurrentGraphRuntimeState() {
return context.currentGraph;
},
beginStageAbortController() {
return {
signal: { aborted: false },
abort() {},
};
},
finishStageAbortController() {},
updateStageNotice(...args) {
context.notices.push(args);
},
inspectHistoryMutation() {
return context.inspectHistoryMutationImpl();
},
inspectHistoryMutationImpl() {
return {
dirty: true,
earliestAffectedFloor: 0,
source: "manual-test",
reason: "edited",
};
},
getContext() {
return {
chat: context.chat,
chatId: "chat-main",
};
},
getCurrentChatId() {
return "chat-main";
},
clampRecoveryStartFloor(chat, floor) {
return Math.max(0, Number(floor) || 0);
},
throwIfAborted(signal, message = "aborted") {
if (signal?.aborted) {
const error = new Error(message);
error.name = "AbortError";
throw error;
}
},
createAbortError(message = "aborted") {
const error = new Error(message);
error.name = "AbortError";
return error;
},
isAbortError(error) {
return error?.name === "AbortError";
},
findJournalRecoveryPoint(graph, floor) {
return context.findJournalRecoveryPointImpl(graph, floor);
},
findJournalRecoveryPointImpl() {
return null;
},
buildReverseJournalRecoveryPlan(...args) {
return context.buildReverseJournalRecoveryPlanImpl(...args);
},
buildReverseJournalRecoveryPlanImpl() {
return {
valid: true,
backendDeleteHashes: [],
replayRequiredNodeIds: [],
pendingRepairFromFloor: 0,
legacyGapFallback: false,
dirtyReason: "history-recovery-replay",
};
},
rollbackAffectedJournals() {},
normalizeGraphRuntimeState(graph) {
return graph;
},
createEmptyGraph() {
return {
historyState: {
extractionCount: 0,
lastMutationSource: "",
lastMutationReason: "",
},
vectorIndexState: {
collectionId: "col-1",
dirty: false,
dirtyReason: "",
pendingRepairFromFloor: null,
replayRequiredNodeIds: [],
lastWarning: "",
lastIntegrityIssue: null,
},
batchJournal: [],
lastProcessedSeq: -1,
};
},
getEmbeddingConfig() {
return context.embeddingConfig;
},
getSettings() {
return {};
},
isBackendVectorConfig(config) {
return config?.mode === "backend";
},
async deleteBackendVectorHashesForRecovery(...args) {
context.deletedHashesCalls ||= [];
context.deletedHashesCalls.push(args);
},
async prepareVectorStateForReplay(...args) {
context.prepareVectorStateCalls.push(args);
if (typeof context.prepareVectorStateForReplayImpl === "function") {
return await context.prepareVectorStateForReplayImpl(...args);
}
},
applyRecoveryPlanToVectorState() {},
async replayExtractionFromHistory(...args) {
if (typeof context.replayExtractionFromHistoryImpl === "function") {
return await context.replayExtractionFromHistoryImpl(...args);
}
return 0;
},
clearHistoryDirty(graph, result) {
context.clearedHistoryDirty = result;
graph.historyState ||= {};
graph.historyState.historyDirtyFrom = null;
graph.historyState.processedMessageHashes = {};
graph.historyState.lastRecoveryResult = result;
},
buildRecoveryResult(status, extra = {}) {
return {
status,
...extra,
};
},
saveGraphToChat() {
context.saveGraphToChatCalls += 1;
},
clearInjectionState() {},
assertRecoveryChatStillActive() {},
refreshPanelLiveState() {
context.refreshPanelCalls += 1;
},
toastr: {
success() {},
warning() {},
error() {},
},
};
vm.createContext(context);
vm.runInContext(
`${snippet}\nresult = { recoverFromHistoryMutation: recoverHistoryIfNeeded };`,
context,
{ filename: indexPath },
);
return context;
});
}
function createRerollHarness() { function createRerollHarness() {
return fs.readFile(indexPath, "utf8").then((source) => { return fs.readFile(indexPath, "utf8").then((source) => {
const rollbackStart = source.indexOf( const rollbackStart = source.indexOf(
@@ -487,6 +666,7 @@ function createRerollHarness() {
context.clearedHistoryDirty = result; context.clearedHistoryDirty = result;
graph.historyState ||= {}; graph.historyState ||= {};
graph.historyState.historyDirtyFrom = null; graph.historyState.historyDirtyFrom = null;
graph.historyState.processedMessageHashes = {};
graph.historyState.lastRecoveryResult = result; graph.historyState.lastRecoveryResult = result;
}, },
buildRecoveryResult(status, extra = {}) { buildRecoveryResult(status, extra = {}) {
@@ -2869,6 +3049,7 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
assert.equal(result.rollbackPerformed, true); assert.equal(result.rollbackPerformed, true);
assert.equal(result.recoveryPath, "reverse-journal"); assert.equal(result.recoveryPath, "reverse-journal");
assert.equal(result.effectiveFromFloor, 2); assert.equal(result.effectiveFromFloor, 2);
assert.equal(result.resultCode, "reroll.rollback.applied");
assert.equal(harness.rollbackAffectedJournalsCalls.length, 1); assert.equal(harness.rollbackAffectedJournalsCalls.length, 1);
assert.equal(harness.deletedHashesCalls.length, 1); assert.equal(harness.deletedHashesCalls.length, 1);
assert.equal(harness.prepareVectorStateCalls.length, 1); assert.equal(harness.prepareVectorStateCalls.length, 1);
@@ -2881,9 +3062,252 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
harness.currentGraph.historyState.processedMessageHashes[3], harness.currentGraph.historyState.processedMessageHashes[3],
undefined, undefined,
); );
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
"reroll.rollback.applied",
);
assert.equal(harness.lastExtractedItems.length, 0); assert.equal(harness.lastExtractedItems.length, 0);
} }
async function testRerollRejectsInvalidReverseJournalPlanFailClosed() {
const harness = await createRerollHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
{ is_user: true, mes: "u2" },
{ is_user: false, mes: "a2" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 3,
processedMessageHashes: {
1: "hash-1",
3: "hash-3",
},
lastRecoveryResult: null,
},
vectorIndexState: {
collectionId: "col-1",
},
batchJournal: [{ id: "journal-1" }],
lastProcessedSeq: 3,
};
harness.findJournalRecoveryPointImpl = () => ({
path: "reverse-journal",
affectedBatchCount: 1,
affectedJournals: [{ id: "journal-1" }],
});
harness.buildReverseJournalRecoveryPlanImpl = () => ({
valid: false,
invalidReason: "pending-repair-floor-missing",
backendDeleteHashes: [],
replayRequiredNodeIds: [],
});
const result = await harness.result.onReroll({ fromFloor: 3 });
assert.equal(result.success, false);
assert.equal(result.recoveryPath, "reverse-journal-rejected");
assert.equal(result.resultCode, "reroll.rollback.plan-invalid");
assert.equal(harness.rollbackAffectedJournalsCalls.length, 0);
assert.equal(harness.prepareVectorStateCalls.length, 0);
assert.equal(harness.deletedHashesCalls.length, 0);
assert.equal(harness.saveGraphToChatCalls, 1);
assert.equal(harness.refreshPanelCalls, 1);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.status,
"reroll-rollback-rejected",
);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
"reroll.rollback.plan-invalid",
);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
"reroll-rollback-plan-invalid:pending-repair-floor-missing",
);
}
async function testHistoryRecoveryAbortClearsVectorRepairState() {
const harness = await createHistoryRecoveryHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 1,
processedMessageHashes: { 1: "hash-1" },
historyDirtyFrom: 1,
lastMutationSource: "message-edited",
},
vectorIndexState: {
collectionId: "col-1",
dirty: true,
dirtyReason: "history-recovery-replay",
pendingRepairFromFloor: 1,
replayRequiredNodeIds: ["node-1"],
lastWarning: "repair pending",
lastIntegrityIssue: { code: "dangling-vector" },
},
batchJournal: [],
lastProcessedSeq: 1,
};
harness.findJournalRecoveryPointImpl = () => ({
path: "full-rebuild",
affectedBatchCount: 0,
});
harness.prepareVectorStateForReplayImpl = async () => {
throw harness.createAbortError("manual abort");
};
const result = await harness.result.recoverFromHistoryMutation({
trigger: "message-edited",
dirtyFrom: 1,
detection: { source: "manual-test", reason: "edited" },
});
assert.equal(result, false);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
"history.recovery.aborted",
);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
"history-recovery-aborted:full-rebuild",
);
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
assert.equal(harness.currentGraph.vectorIndexState.lastWarning, "");
assert.equal(
harness.currentGraph.vectorIndexState.pendingRepairFromFloor,
null,
);
assert.equal(
harness.currentGraph.vectorIndexState.replayRequiredNodeIds.length,
0,
);
assert.equal(harness.currentGraph.vectorIndexState.dirty, false);
assert.equal(harness.currentGraph.vectorIndexState.dirtyReason, "");
}
async function testHistoryRecoveryFallbackFullRebuildCarriesResultCode() {
const harness = await createHistoryRecoveryHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 1,
processedMessageHashes: { 1: "hash-1" },
historyDirtyFrom: 1,
lastMutationSource: "message-edited",
},
vectorIndexState: {
collectionId: "col-1",
dirty: true,
dirtyReason: "history-recovery-replay",
pendingRepairFromFloor: 1,
replayRequiredNodeIds: ["node-1"],
lastWarning: "repair pending",
lastIntegrityIssue: { code: "dangling-vector" },
},
batchJournal: [],
lastProcessedSeq: 1,
};
harness.findJournalRecoveryPointImpl = () => ({
path: "legacy-snapshot",
affectedBatchCount: 2,
snapshotBefore: {
historyState: { extractionCount: 0 },
vectorIndexState: { collectionId: "col-1" },
batchJournal: [],
lastProcessedSeq: -1,
},
});
let replayCallCount = 0;
harness.replayExtractionFromHistoryImpl = async () => {
replayCallCount += 1;
if (replayCallCount === 1) {
throw new Error("replay failed");
}
return 1;
};
const result = await harness.result.recoverFromHistoryMutation({
trigger: "message-edited",
dirtyFrom: 1,
detection: { source: "manual-test", reason: "edited" },
});
assert.equal(result, true);
assert.equal(
harness.clearedHistoryDirty.resultCode,
"history.recovery.fallback-full-rebuild",
);
assert.equal(
harness.clearedHistoryDirty.debugReason,
"history-recovery-fallback-full-rebuild:legacy-snapshot",
);
}
async function testHistoryRecoveryFailureCarriesResultCode() {
const harness = await createHistoryRecoveryHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 1,
processedMessageHashes: { 1: "hash-1" },
historyDirtyFrom: 1,
lastMutationSource: "message-edited",
},
vectorIndexState: {
collectionId: "col-1",
dirty: true,
dirtyReason: "history-recovery-replay",
pendingRepairFromFloor: 1,
replayRequiredNodeIds: ["node-1"],
lastWarning: "repair pending",
lastIntegrityIssue: { code: "dangling-vector" },
},
batchJournal: [],
lastProcessedSeq: 1,
};
harness.findJournalRecoveryPointImpl = () => ({
path: "legacy-snapshot",
affectedBatchCount: 1,
snapshotBefore: {
historyState: { extractionCount: 0 },
vectorIndexState: { collectionId: "col-1" },
batchJournal: [],
lastProcessedSeq: -1,
},
});
harness.replayExtractionFromHistoryImpl = async () => {
throw new Error("replay failed twice");
};
const result = await harness.result.recoverFromHistoryMutation({
trigger: "message-edited",
dirtyFrom: 1,
detection: { source: "manual-test", reason: "edited" },
});
assert.equal(result, false);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.resultCode,
"history.recovery.failed",
);
assert.equal(
harness.currentGraph.historyState.lastRecoveryResult.debugReason,
"history-recovery-failed:legacy-snapshot",
);
assert.equal(harness.currentGraph.vectorIndexState.lastIntegrityIssue, null);
}
async function testRerollRejectsMissingRecoveryPoint() { async function testRerollRejectsMissingRecoveryPoint() {
const harness = await createRerollHarness(); const harness = await createRerollHarness();
harness.chat = [ harness.chat = [
@@ -2911,6 +3335,7 @@ async function testRerollRejectsMissingRecoveryPoint() {
assert.equal(result.success, false); assert.equal(result.success, false);
assert.equal(result.recoveryPath, "unavailable"); assert.equal(result.recoveryPath, "unavailable");
assert.equal(result.resultCode, "reroll.rollback.unavailable");
assert.equal(harness.onManualExtractCalls, 0); assert.equal(harness.onManualExtractCalls, 0);
assert.equal(harness.saveGraphToChatCalls, 0); assert.equal(harness.saveGraphToChatCalls, 0);
} }
@@ -2943,6 +3368,7 @@ async function testRerollFallsBackToDirectExtractForUnprocessedFloor() {
assert.equal(result.rollbackPerformed, false); assert.equal(result.rollbackPerformed, false);
assert.equal(result.recoveryPath, "direct-extract"); assert.equal(result.recoveryPath, "direct-extract");
assert.equal(result.effectiveFromFloor, 2); assert.equal(result.effectiveFromFloor, 2);
assert.equal(result.resultCode, undefined);
assert.equal(harness.onManualExtractCalls, 1); assert.equal(harness.onManualExtractCalls, 1);
assert.equal(harness.saveGraphToChatCalls, 0); assert.equal(harness.saveGraphToChatCalls, 0);
} }
@@ -3188,6 +3614,9 @@ await testRecallCardExpandedContentRerendersAfterRecordUpdate();
await testRecallCardUserTextRefreshesWithoutCardRecreate(); await testRecallCardUserTextRefreshesWithoutCardRecreate();
await testRecallSubGraphAndDataLayerEntryPoints(); await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testHistoryRecoveryAbortClearsVectorRepairState();
await testHistoryRecoveryFallbackFullRebuildCarriesResultCode();
await testHistoryRecoveryFailureCarriesResultCode();
await testRerollRejectsMissingRecoveryPoint(); await testRerollRejectsMissingRecoveryPoint();
await testRerollFallsBackToDirectExtractForUnprocessedFloor(); await testRerollFallsBackToDirectExtractForUnprocessedFloor();
await testLlmDebugSnapshotRedactsSecretsBeforeStorage(); await testLlmDebugSnapshotRedactsSecretsBeforeStorage();