From 5a53d010afb9ff491fec49c8a6c6ad6c21eb4abe Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sun, 12 Apr 2026 16:35:04 +0800 Subject: [PATCH] fix: reconcile stale manualBackupBatchJournalCoverage when contiguous earlier journal coverage is rebuilt - Add hasContiguousJournalCoverageThroughFloor to check if current batchJournal contiguously covers back through the old retained floor - Add reconcileManualBackupBatchJournalCoverage that clears stale coverage when actual journals have bridged the gap - Self-clean during normalizeGraphRuntimeState (load-time) and appendBatchJournal (write-time) - findJournalRecoveryPoint now uses reconciled coverage so stale manual backup floors no longer block valid reverse-journal recovery - Add regression tests for gap-not-yet-bridged and bridged scenarios --- runtime/runtime-state.js | 90 ++++++++++++++++++++++++++++++++++++++- tests/runtime-history.mjs | 63 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index ee22d26..f6d3096 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -124,10 +124,88 @@ function getEarliestJournalCoverageStartFloor(journals = []) { return earliestFloor; } +function hasContiguousJournalCoverageThroughFloor(journals = [], targetFloor = null) { + const normalizedTargetFloor = Number.isFinite(Number(targetFloor)) + ? Math.max(0, Math.floor(Number(targetFloor))) + : null; + if (!Number.isFinite(normalizedTargetFloor)) { + return false; + } + + const ranges = (Array.isArray(journals) ? journals : []) + .map((journal) => { + const range = Array.isArray(journal?.processedRange) + ? journal.processedRange + : []; + const startFloor = Number(range[0]); + const endFloor = Number(range[1]); + if (!Number.isFinite(startFloor) || !Number.isFinite(endFloor)) { + return null; + } + return { + start: Math.max(0, Math.floor(startFloor)), + end: Math.max(0, Math.floor(endFloor)), + }; + }) + .filter(Boolean) + .sort((left, right) => left.start - right.start || left.end - right.end); + + if (ranges.length === 0) { + return false; + } + + let coveredUntil = null; + for (const range of ranges) { + if (coveredUntil == null) { + if (range.start > normalizedTargetFloor) { + return false; + } + coveredUntil = range.end; + } else if (range.start > coveredUntil + 1) { + return coveredUntil >= normalizedTargetFloor; + } else { + coveredUntil = Math.max(coveredUntil, range.end); + } + + if (coveredUntil >= normalizedTargetFloor) { + return true; + } + } + + return false; +} + +function reconcileManualBackupBatchJournalCoverage(coverage = null, journals = []) { + const normalizedCoverage = normalizeManualBackupBatchJournalCoverage(coverage); + if (!normalizedCoverage) { + return null; + } + + const manualCoverageFloor = + normalizedCoverage.truncated === true && + Number.isFinite(normalizedCoverage.earliestRetainedFloor) + ? normalizedCoverage.earliestRetainedFloor + : null; + const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals); + + if ( + normalizedCoverage.truncated === true && + Number.isFinite(actualCoverageFloor) && + Number.isFinite(manualCoverageFloor) && + actualCoverageFloor < manualCoverageFloor && + hasContiguousJournalCoverageThroughFloor(journals, manualCoverageFloor) + ) { + return null; + } + + return normalizedCoverage; +} + function getRequiredJournalCoverageStartFloor(graph, journals = []) { const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals); - const manualCoverage = normalizeManualBackupBatchJournalCoverage( + const manualCoverage = reconcileManualBackupBatchJournalCoverage( graph?.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + journals, ); const manualCoverageFloor = manualCoverage?.truncated === true && @@ -406,6 +484,11 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { graph.batchJournal = Array.isArray(graph.batchJournal) ? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT) : createDefaultBatchJournal(); + historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = + reconcileManualBackupBatchJournalCoverage( + historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + graph.batchJournal, + ); graph.maintenanceJournal = Array.isArray(graph.maintenanceJournal) ? graph.maintenanceJournal .filter((entry) => entry && typeof entry === "object") @@ -955,6 +1038,11 @@ export function appendBatchJournal(graph, entry) { if (graph.batchJournal.length > BATCH_JOURNAL_LIMIT) { graph.batchJournal = graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT); } + graph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = + reconcileManualBackupBatchJournalCoverage( + graph.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + graph.batchJournal, + ); } export function createMaintenanceJournalEntry( diff --git a/tests/runtime-history.mjs b/tests/runtime-history.mjs index 200c0b0..efe82ef 100644 --- a/tests/runtime-history.mjs +++ b/tests/runtime-history.mjs @@ -291,6 +291,69 @@ assert.ok(retainedCoverageRecoveryPoint); assert.equal(retainedCoverageRecoveryPoint.path, "reverse-journal"); assert.equal(retainedCoverageRecoveryPoint.affectedJournals.length, 3); +const bridgedCoverageGraph = createEmptyGraph(); +bridgedCoverageGraph.historyState.chatId = "chat-bridged-history-test"; +bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = { + truncated: true, + earliestRetainedFloor: 4, + retainedCount: 4, +}; +bridgedCoverageGraph.batchJournal = [ + { id: "journal-4", journalVersion: 2, processedRange: [4, 4] }, + { id: "journal-5", journalVersion: 2, processedRange: [5, 5] }, +]; +appendBatchJournal(bridgedCoverageGraph, { + id: "journal-2", + journalVersion: 2, + processedRange: [2, 2], +}); +assert.deepEqual( + bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + { + truncated: true, + earliestRetainedFloor: 4, + retainedCount: 4, + }, +); +appendBatchJournal(bridgedCoverageGraph, { + id: "journal-3", + journalVersion: 2, + processedRange: [3, 3], +}); +assert.equal( + bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + null, +); + +const recoveredCoverageGraph = createEmptyGraph(); +recoveredCoverageGraph.historyState.chatId = "chat-recovered-history-test"; +recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = { + truncated: true, + earliestRetainedFloor: 4, + retainedCount: 4, +}; +recoveredCoverageGraph.batchJournal = [ + { id: "journal-2", journalVersion: 2, processedRange: [2, 2] }, + { id: "journal-3", journalVersion: 2, processedRange: [3, 3] }, + { id: "journal-4", journalVersion: 2, processedRange: [4, 4] }, + { id: "journal-5", journalVersion: 2, processedRange: [5, 5] }, +]; +normalizeGraphRuntimeState( + recoveredCoverageGraph, + recoveredCoverageGraph.historyState.chatId, +); +assert.equal( + recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], + null, +); +const recoveredCoverageRecoveryPoint = findJournalRecoveryPoint( + recoveredCoverageGraph, + 2, +); +assert.ok(recoveredCoverageRecoveryPoint); +assert.equal(recoveredCoverageRecoveryPoint.path, "reverse-journal"); +assert.equal(recoveredCoverageRecoveryPoint.affectedJournals.length, 4); + rollbackBatch(graph, recoveryPoint.affectedJournals[0]); assert.equal(graph.nodes.length, 0); assert.equal(graph.historyState.lastProcessedAssistantFloor, -1);