feat(kernel): stage-level plan checkpointing (C-A2)

- StageCheckpointPassed/FailedEvent (registered + serialization test): per-stage
  reconciliation of produced artifacts vs the locked plan's produces-slots
- StageCheckpointReconciler (pure) + tests
- emitStageCheckpoint wired in after verifyProduces, runs regardless of its
  pass/fail (verifyProduces still owns the halt); gated on an ExecutionPlanLocked
  in the session log so non-plan workflows are unaffected

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:53:41 +00:00
parent 1df7af5b85
commit 1a1b5cc5ed
6 changed files with 192 additions and 1 deletions
@@ -0,0 +1,42 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *passed* variant means every expected slot was produced: [producedArtifacts] is a superset of
* [expectedArtifacts] (extra artifacts beyond the expected set do not fail the checkpoint).
*/
@Serializable
@SerialName("StageCheckpointPassed")
data class StageCheckpointPassedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
) : EventPayload
/**
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
*
* The *failed* variant accompanies the stage halt owned by `verifyProduces` and surfaces *why* the
* plan diverged: [missingArtifacts] are the expected slots ([expectedArtifacts] minus
* [producedArtifacts]) the stage never produced.
*/
@Serializable
@SerialName("StageCheckpointFailed")
data class StageCheckpointFailedEvent(
val sessionId: SessionId,
val stageId: StageId,
val expectedArtifacts: Set<String>,
val producedArtifacts: Set<String>,
val missingArtifacts: Set<String>,
) : EventPayload
@@ -45,6 +45,8 @@ import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
@@ -131,6 +133,8 @@ val eventModule = SerializersModule {
subclass(IdeaCapturedEvent::class)
subclass(IdeaDiscardedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class)
}
}
@@ -0,0 +1,42 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StageCheckpointEventSerializationTest {
@Test
fun `StageCheckpointPassedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointPassedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json"),
producedArtifacts = setOf("plan.json", "notes.md"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointPassed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `StageCheckpointFailedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = StageCheckpointFailedEvent(
sessionId = SessionId("s"),
stageId = StageId("plan"),
expectedArtifacts = setOf("plan.json", "diff.patch"),
producedArtifacts = setOf("plan.json"),
missingArtifacts = setOf("diff.patch"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StageCheckpointFailed\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}