feat(server-04): fix snapshot cursor order, deterministic approvals, unconditional SnapshotComplete

Capture lastGlobalSequence() before any projection read to anchor the
buffer-drain window; capture lastSequence(sessionId) before each session's
projection read. SessionSnapshot.lastSequence now carries the global cursor
shared across all sessions in the batch. pendingApprovals populated from
filtered approval state and sorted by (timestamp, requestId) for deterministic
ordering. SnapshotComplete emitted unconditionally including the zero-sessions
case. Four new tests: zero-sessions, SnapshotComplete-last ordering, global
cursor isolation, and per-session cursor value.
This commit is contained in:
2026-05-24 23:01:21 +04:00
parent ac05ad8733
commit 14141f2f72
2 changed files with 68 additions and 10 deletions
@@ -1,5 +1,6 @@
package com.correx.apps.server.bridge package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ApprovalDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.server.protocol.SessionStateDto
import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.approvals.DefaultApprovalRepository
@@ -17,20 +18,26 @@ class SessionEventBridge(
private val send: suspend (ServerMessage) -> Unit, private val send: suspend (ServerMessage) -> Unit,
) { ) {
suspend fun replaySnapshot() { suspend fun replaySnapshot() {
val lastGlobal = eventStore.lastGlobalSequence()
eventStore.allSessionIds().forEach { sessionId -> eventStore.allSessionIds().forEach { sessionId ->
val orchState = orchestrationRepository.getState(sessionId) val orchState = orchestrationRepository.getState(sessionId)
if (orchState.status == OrchestrationStatus.IDLE) return@forEach if (orchState.status == OrchestrationStatus.IDLE) return@forEach
val lastSession = eventStore.lastSequence(sessionId) ?: 0L
val approvalState = if (orchState.pendingApproval) { val approvalState = if (orchState.pendingApproval) {
approvalRepository.getApprovalState(sessionId) approvalRepository.getApprovalState(sessionId)
} else null } else null
val pendingRequest = approvalState?.requests?.values val pendingApprovals = approvalState?.requests?.values
?.firstOrNull { req -> ?.filter { req -> approvalState.decisions.values.none { it.requestId == req.id } }
approvalState.decisions.values.none { it.requestId == req.id } ?.sortedWith(compareBy({ it.timestamp }, { it.id.value }))
} ?.map { ApprovalDto(requestId = it.id.value, tier = it.tier.name) }
?: emptyList()
val pendingRequest = approvalState?.requests?.values
?.firstOrNull { req -> approvalState.decisions.values.none { it.requestId == req.id } }
val lastSeq = eventStore.lastSequence(sessionId) ?: 0L
send(ServerMessage.SessionSnapshot( send(ServerMessage.SessionSnapshot(
sessionId = sessionId, sessionId = sessionId,
workflowId = orchState.workflowId, workflowId = orchState.workflowId,
@@ -47,9 +54,9 @@ class SessionEventBridge(
currentStageId = orchState.currentStageId?.value, currentStageId = orchState.currentStageId?.value,
pauseReason = orchState.pauseReason, pauseReason = orchState.pauseReason,
), ),
pendingApprovals = emptyList(), pendingApprovals = pendingApprovals,
lastSequence = lastSeq, lastSequence = lastGlobal,
lastSessionSequence = 0L, lastSessionSequence = lastSession,
)) ))
} }
send(ServerMessage.SnapshotComplete) send(ServerMessage.SnapshotComplete)
@@ -74,7 +74,8 @@ class SessionEventBridgeTest {
override fun allEvents(): Sequence<StoredEvent> = allEventsList.asSequence() override fun allEvents(): Sequence<StoredEvent> = allEventsList.asSequence()
override fun allSessionIds(): Set<SessionId> = allEventsList.map { it.metadata.sessionId }.toSet() override fun allSessionIds(): Set<SessionId> = allEventsList.map { it.metadata.sessionId }.toSet()
override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context") override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context")
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context") override suspend fun lastGlobalSequence(): Long =
allEventsList.maxOfOrNull { it.sequence } ?: 0L
} }
private fun activeOrchestrationRepository( private fun activeOrchestrationRepository(
@@ -119,7 +120,7 @@ class SessionEventBridgeTest {
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(sessionId, snapshot.sessionId) assertEquals(sessionId, snapshot.sessionId)
assertEquals(2L, snapshot.lastSequence) assertEquals(2L, snapshot.lastSequence)
assertEquals(0L, snapshot.lastSessionSequence) assertEquals(2L, snapshot.lastSessionSequence)
assertEquals(ServerMessage.SnapshotComplete, sent[1]) assertEquals(ServerMessage.SnapshotComplete, sent[1])
} }
@@ -176,6 +177,56 @@ class SessionEventBridgeTest {
) )
} }
@Test
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
val store = fakeEventStore(allEventsList = emptyList())
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(1, sent.size)
assertEquals(ServerMessage.SnapshotComplete, sent[0])
}
@Test
fun `replaySnapshot emits SnapshotComplete as final message`() = runTest {
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(ServerMessage.SnapshotComplete, sent.last())
}
@Test
fun `replaySnapshot lastSequence reflects global cursor before projection read`() = runTest {
val events = listOf(
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
)
val store = object : EventStore by fakeEventStore(allEventsList = events) {
override suspend fun lastGlobalSequence(): Long = 5L
}
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(5L, snapshot.lastSequence)
}
@Test
fun `replaySnapshot lastSessionSequence equals per-session cursor`() = runTest {
val events = listOf(
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 3L),
)
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(3L, snapshot.lastSessionSequence)
}
@Test @Test
fun `streamLive cancellation stops collection`() = runTest { fun `streamLive cancellation stops collection`() = runTest {
val channel = Channel<StoredEvent>(Channel.UNLIMITED) val channel = Channel<StoredEvent>(Channel.UNLIMITED)