diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RefinementEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RefinementEvents.kt new file mode 100644 index 00000000..b6020494 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RefinementEvents.kt @@ -0,0 +1,19 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Records one pass through a refinement cycle (e.g. implementer→reviewer→implementer). + * [cycleKey] identifies the loop (e.g. "implement->review"). [iteration] is 1-based. + * Recorded so the runtime refinement guard is replay-deterministic (invariant #8). + */ +@Serializable +@SerialName("RefinementIteration") +data class RefinementIterationEvent( + val sessionId: SessionId, + val cycleKey: String, + val iteration: Int, + val maxIterations: Int, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt index c3274970..7e0a5a4b 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/orchestration/OrchestrationState.kt @@ -14,4 +14,5 @@ data class OrchestrationState( val pendingApproval: Boolean = false, val failureReason: String? = null, val retryPolicy: RetryPolicy? = null, + val refinementIterations: Map = emptyMap(), ) diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 2fa641d9..1db44e84 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -23,6 +23,7 @@ import com.correx.core.events.events.ModelLoadedEvent import com.correx.core.events.events.ModelUnloadedEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.StageCompletedEvent @@ -78,6 +79,7 @@ val eventModule = SerializersModule { subclass(WorkflowFailedEvent::class) subclass(WorkflowCompletedEvent::class) subclass(RetryAttemptedEvent::class) + subclass(RefinementIterationEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) subclass(ChatTurnEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/RefinementEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/RefinementEventSerializationTest.kt new file mode 100644 index 00000000..89a06fe3 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/RefinementEventSerializationTest.kt @@ -0,0 +1,25 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.types.SessionId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RefinementEventSerializationTest { + + @Test + fun `RefinementIterationEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = RefinementIterationEvent( + sessionId = SessionId("s"), + cycleKey = "implement->review", + iteration = 2, + maxIterations = 3, + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"RefinementIteration\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt index c2633cac..325ff4be 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultOrchestrationReducer.kt @@ -3,6 +3,7 @@ package com.correx.core.kernel.orchestration import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent +import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.TransitionExecutedEvent @@ -60,6 +61,10 @@ class DefaultOrchestrationReducer : OrchestrationReducer { failureReason = null, ) + is RefinementIterationEvent -> state.copy( + refinementIterations = state.refinementIterations + (p.cycleKey to p.iteration), + ) + else -> state } } diff --git a/testing/replay/build.gradle b/testing/replay/build.gradle index b2179194..10fe62b0 100644 --- a/testing/replay/build.gradle +++ b/testing/replay/build.gradle @@ -7,6 +7,7 @@ plugins { dependencies { testImplementation(project(":core:events")) testImplementation(project(":core:journal")) + testImplementation(project(":core:kernel")) testImplementation(project(":core:sessions")) testImplementation(project(":core:transitions")) testImplementation(project(":core:validation")) diff --git a/testing/replay/src/test/kotlin/RefinementIterationReplayTest.kt b/testing/replay/src/test/kotlin/RefinementIterationReplayTest.kt new file mode 100644 index 00000000..ae0291a7 --- /dev/null +++ b/testing/replay/src/test/kotlin/RefinementIterationReplayTest.kt @@ -0,0 +1,35 @@ +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RefinementIterationReplayTest { + + private fun meta(sessionId: SessionId, id: String) = + EventMetadata(EventId(id), sessionId, Clock.System.now(), 1, null, null) + + @Test + fun `refinement iteration counter tracks last value per cycle`(): Unit = runBlocking { + val sessionId = SessionId("s") + val store = InMemoryEventStore() + store.append(NewEvent(meta(sessionId, "i1"), RefinementIterationEvent(sessionId, "impl->review", 1, 3))) + store.append(NewEvent(meta(sessionId, "i2"), RefinementIterationEvent(sessionId, "impl->review", 2, 3))) + + val replayer = DefaultEventReplayer( + store, + OrchestrationProjector(DefaultOrchestrationReducer()), + ) + val state = replayer.rebuild(sessionId) + + assertEquals(2, state.refinementIterations["impl->review"]) + } +}