feat(events,server): ExecutionPlanRejectedEvent — freestyle compile failure visible in log

Both silent-failure branches in FreestyleDriver.lockAndRun now emit
ExecutionPlanRejectedEvent (source=compile or missing_content) before
returning, satisfying invariant #1. Event registered in eventModule for
correct polymorphic serialization.
This commit is contained in:
2026-06-10 21:26:18 +04:00
parent fba2bbb17e
commit 0553b1b6ee
5 changed files with 76 additions and 0 deletions
@@ -2,6 +2,7 @@ package com.correx.apps.server.freestyle
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactId
@@ -32,11 +33,13 @@ class FreestyleDriver(
suspend fun lockAndRun(sessionId: SessionId) { suspend fun lockAndRun(sessionId: SessionId) {
val json = planContent(sessionId) ?: run { val json = planContent(sessionId) ?: run {
log.warn("freestyle: no execution_plan content for session={}", sessionId.value) log.warn("freestyle: no execution_plan content for session={}", sessionId.value)
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
return return
} }
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") } val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
.getOrElse { .getOrElse {
log.error("freestyle: plan failed to compile: {}", it.message) log.error("freestyle: plan failed to compile: {}", it.message)
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
return return
} }
eventStore.append( eventStore.append(
@@ -61,6 +64,26 @@ class FreestyleDriver(
runPhase2(sessionId, graph, config) runPhase2(sessionId, graph, config)
} }
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ExecutionPlanRejectedEvent(
sessionId = sessionId,
reason = reason,
source = source,
),
),
)
}
companion object { companion object {
private val log = LoggerFactory.getLogger(FreestyleDriver::class.java) private val log = LoggerFactory.getLogger(FreestyleDriver::class.java)
} }
@@ -4,6 +4,7 @@ import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
@@ -123,6 +124,12 @@ class FreestyleDriverTest {
assertTrue(lockedEvents.isEmpty(), "No lock event expected for malformed plan") assertTrue(lockedEvents.isEmpty(), "No lock event expected for malformed plan")
assertEquals(0, runPhase2Invocations, "runPhase2 must not be called for malformed plan") assertEquals(0, runPhase2Invocations, "runPhase2 must not be called for malformed plan")
val rejectedEvents = eventStore.read(sessionId)
.map { it.payload }
.filterIsInstance<ExecutionPlanRejectedEvent>()
assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent")
assertEquals("compile", rejectedEvents.single().source)
} }
@Test @Test
@@ -153,5 +160,11 @@ class FreestyleDriverTest {
assertTrue(lockedEvents.isEmpty(), "No lock event when plan content is absent") assertTrue(lockedEvents.isEmpty(), "No lock event when plan content is absent")
assertEquals(0, runPhase2Invocations, "runPhase2 must not be called when plan is absent") assertEquals(0, runPhase2Invocations, "runPhase2 must not be called when plan is absent")
val rejectedEvents = eventStore.read(sessionId)
.map { it.payload }
.filterIsInstance<ExecutionPlanRejectedEvent>()
assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent")
assertEquals("missing_content", rejectedEvents.single().source)
} }
} }
@@ -20,3 +20,17 @@ data class ExecutionPlanLockedEvent(
val stageIds: List<String>, val stageIds: List<String>,
val startStageId: String, val startStageId: String,
) : EventPayload ) : EventPayload
/**
* The architect's execution_plan could not become the phase-2 graph: content missing,
* compile/validation failure, or operator rejection at the plan-review gate. Terminal for
* the freestyle run unless re-planned; recorded so replay explains why phase 2 never ran.
*/
@Serializable
@SerialName("ExecutionPlanRejected")
data class ExecutionPlanRejectedEvent(
val sessionId: SessionId,
val reason: String,
/** "compile" | "missing_content" | "operator" */
val source: String,
) : EventPayload
@@ -16,6 +16,7 @@ import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.JournalCompactedEvent import com.correx.core.events.events.JournalCompactedEvent
import com.correx.core.events.events.FileWrittenEvent import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent import com.correx.core.events.events.L3MemoryRetrievedEvent
@@ -102,6 +103,7 @@ val eventModule = SerializersModule {
subclass(L3MemoryRetrievedEvent::class) subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class) subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class) subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class)
subclass(JournalCompactedEvent::class) subclass(JournalCompactedEvent::class)
} }
} }
@@ -0,0 +1,24 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ExecutionPlanRejectedEventSerializationTest {
@Test
fun `ExecutionPlanRejectedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ExecutionPlanRejectedEvent(
sessionId = SessionId("s"),
reason = "plan failed to compile: Unknown artifact kind",
source = "compile",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ExecutionPlanRejected\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}