diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 1e6fb60..7cb4ee0 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -176,12 +176,31 @@ function buildCommittedBatchPersistSnapshot( }; } +function isPersistenceRevisionAccepted(runtime, persistence = null) { + if (!persistence || persistence.accepted === true) return true; + const graphPersistenceState = runtime?.getGraphPersistenceState?.() || {}; + if (graphPersistenceState.pendingPersist === true) { + return false; + } + const persistenceRevision = Number(persistence?.revision || 0); + if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { + return false; + } + const lastAcceptedRevision = Math.max( + Number(graphPersistenceState?.lastAcceptedRevision || 0), + Number(graphPersistenceState?.commitMarker?.accepted === true + ? graphPersistenceState?.commitMarker?.revision + : 0), + ); + return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; +} + function getPendingPersistenceGateInfo(runtime) { const graph = runtime?.getCurrentGraph?.(); const batchStatus = graph?.historyState?.lastBatchStatus || null; const persistence = batchStatus?.persistence || null; const pendingPersist = runtime?.getGraphPersistenceState?.()?.pendingPersist === true; - const accepted = persistence?.accepted === true; + const accepted = isPersistenceRevisionAccepted(runtime, persistence); if (!pendingPersist && (!persistence || accepted)) { return null; } diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index ff0399d..cf809d7 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -206,6 +206,129 @@ async function testManualExtractNoBatchesDoesNotStayRunning() { assert.notEqual(context.runtimeStatus.level, "running"); } +async function testManualExtractIgnoresSupersededPendingPersistence() { + let executeExtractionBatchCalls = 0; + let assistantTurnCallCount = 0; + const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; + const context = { + ...createBaseStatusContext(), + isExtracting: false, + graphPersistenceState: { + pendingPersist: false, + lastAcceptedRevision: 7, + }, + currentGraph: { + historyState: { + lastBatchStatus: { + processedRange: [1, 1], + persistence: { + outcome: "queued", + accepted: false, + revision: 7, + reason: "extraction-batch-complete:pending", + storageTier: "none", + }, + }, + }, + }, + getCurrentChatId() { + return "chat-mobile"; + }, + getCurrentGraph() { + return context.currentGraph; + }, + getIsExtracting() { + return context.isExtracting; + }, + getGraphPersistenceState() { + return { + pendingPersist: false, + lastAcceptedRevision: 7, + }; + }, + ensureGraphMutationReady() { + return true; + }, + async recoverHistoryIfNeeded() { + return true; + }, + normalizeGraphRuntimeState(graph) { + return graph; + }, + setCurrentGraph(graph) { + context.currentGraph = graph; + }, + createEmptyGraph() { + return {}; + }, + getContext() { + return { chat }; + }, + getAssistantTurns() { + assistantTurnCallCount += 1; + return assistantTurnCallCount <= 2 ? [1] : []; + }, + getLastProcessedAssistantFloor() { + return 0; + }, + clampInt(value, fallback) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; + }, + getSettings() { + return { extractEvery: 1 }; + }, + beginStageAbortController() { + return { signal: {} }; + }, + async executeExtractionBatch() { + executeExtractionBatchCalls += 1; + return { + success: true, + result: { + newNodes: 0, + updatedNodes: 0, + newEdges: 0, + }, + effects: {}, + batchStatus: { + persistence: { + accepted: true, + }, + }, + historyAdvanceAllowed: true, + }; + }, + async retryPendingGraphPersist() { + return { + accepted: false, + reason: "no-pending-persist", + }; + }, + isAbortError() { + return false; + }, + onManualExtractController, + finishStageAbortController() {}, + setIsExtracting(value) { + context.isExtracting = value; + }, + setLastExtractionStatus(text, meta, level) { + context.lastExtractionStatus = { text, meta, level }; + context.runtimeStatus = { text, meta, level }; + }, + toastr: { + info() {}, + success() {}, + warning() {}, + error() {}, + }, + result: null, + }; + await onManualExtractController(context, { drainAll: false }); + assert.equal(executeExtractionBatchCalls, 1); + assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认"); +} + async function testManualRebuildSetsTerminalRuntimeStatus() { const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; const context = { @@ -281,6 +404,7 @@ async function testManualRebuildSetsTerminalRuntimeStatus() { testIndexDefinesLastProcessedAssistantFloorHelper(); await testVectorSyncTerminalStateUpdatesRuntime(); await testManualExtractNoBatchesDoesNotStayRunning(); +await testManualExtractIgnoresSupersededPendingPersistence(); await testManualRebuildSetsTerminalRuntimeStatus(); console.log("mobile-status-regressions tests passed"); diff --git a/ui/panel.js b/ui/panel.js index 02add7c..3ea88fd 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -9442,11 +9442,30 @@ function _formatPersistenceOutcomeLabel(outcome = "") { } } +function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { + if (!persistence || persistence.accepted === true) return true; + if (loadInfo?.pendingPersist === true) return false; + const persistenceRevision = Number(persistence?.revision || 0); + if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { + return false; + } + const commitMarkerRevision = + loadInfo?.commitMarker?.accepted === true + ? Number(loadInfo.commitMarker.revision || 0) + : 0; + const lastAcceptedRevision = Math.max( + Number(loadInfo?.lastAcceptedRevision || 0), + commitMarkerRevision, + ); + return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; +} + function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { const persistence = batchStatus?.persistence || null; if (persistence) { + const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ - _formatPersistenceOutcomeLabel(persistence.outcome), + accepted ? "已确认" : _formatPersistenceOutcomeLabel(persistence.outcome), persistence.storageTier ? `tier ${persistence.storageTier}` : "", Number.isFinite(Number(persistence.revision)) && Number(persistence.revision) > 0 ? `rev ${Number(persistence.revision)}` @@ -9477,6 +9496,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = const lastConfirmedFloor = graph?.historyState?.lastProcessedAssistantFloor ?? -1; const persistence = batchStatus?.persistence || null; + const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const processedRange = Array.isArray(batchStatus?.processedRange) ? batchStatus.processedRange : []; @@ -9485,7 +9505,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = ? Number(processedRange[1]) : null; - if (persistence && persistence.accepted !== true && pendingFloor != null) { + if (persistence && !accepted && pendingFloor != null) { return `持久化待确认:本地已抽取到楼层 ${pendingFloor},已确认楼层 ${lastConfirmedFloor}`; }