From 0553b1b6eef715dc12a99b2fa40b49c41d12a3f7 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 10 Jun 2026 21:26:18 +0400 Subject: [PATCH] =?UTF-8?q?feat(events,server):=20ExecutionPlanRejectedEve?= =?UTF-8?q?nt=20=E2=80=94=20freestyle=20compile=20failure=20visible=20in?= =?UTF-8?q?=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/server/freestyle/FreestyleDriver.kt | 23 ++++++++++++++++++ .../server/freestyle/FreestyleDriverTest.kt | 13 ++++++++++ .../core/events/events/ExecutionPlanEvents.kt | 14 +++++++++++ .../events/serialization/Serialization.kt | 2 ++ ...utionPlanRejectedEventSerializationTest.kt | 24 +++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/ExecutionPlanRejectedEventSerializationTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index 4335d790..98b3579d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -2,6 +2,7 @@ package com.correx.apps.server.freestyle import com.correx.core.events.events.EventMetadata 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.stores.EventStore import com.correx.core.events.types.ArtifactId @@ -32,11 +33,13 @@ class FreestyleDriver( suspend fun lockAndRun(sessionId: SessionId) { val json = planContent(sessionId) ?: run { log.warn("freestyle: no execution_plan content for session={}", sessionId.value) + emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content") return } val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") } .getOrElse { log.error("freestyle: plan failed to compile: {}", it.message) + emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile") return } eventStore.append( @@ -61,6 +64,26 @@ class FreestyleDriver( 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 { private val log = LoggerFactory.getLogger(FreestyleDriver::class.java) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt index d88b47f3..00ef6356 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt @@ -4,6 +4,7 @@ import com.correx.core.artifacts.kind.ConfigArtifactKind import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry import com.correx.core.artifacts.kind.JsonSchema 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.kernel.execution.WorkflowResult import com.correx.core.kernel.orchestration.OrchestrationConfig @@ -123,6 +124,12 @@ class FreestyleDriverTest { assertTrue(lockedEvents.isEmpty(), "No lock event expected for malformed plan") assertEquals(0, runPhase2Invocations, "runPhase2 must not be called for malformed plan") + + val rejectedEvents = eventStore.read(sessionId) + .map { it.payload } + .filterIsInstance() + assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent") + assertEquals("compile", rejectedEvents.single().source) } @Test @@ -153,5 +160,11 @@ class FreestyleDriverTest { assertTrue(lockedEvents.isEmpty(), "No lock event when plan content is absent") assertEquals(0, runPhase2Invocations, "runPhase2 must not be called when plan is absent") + + val rejectedEvents = eventStore.read(sessionId) + .map { it.payload } + .filterIsInstance() + assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent") + assertEquals("missing_content", rejectedEvents.single().source) } } diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt index 59a2c7f6..1ac69f0a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt @@ -20,3 +20,17 @@ data class ExecutionPlanLockedEvent( val stageIds: List, val startStageId: String, ) : 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 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 a63326ce..3c136fde 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 @@ -16,6 +16,7 @@ import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.EventPayload 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.FileWrittenEvent import com.correx.core.events.events.L3MemoryRetrievedEvent @@ -102,6 +103,7 @@ val eventModule = SerializersModule { subclass(L3MemoryRetrievedEvent::class) subclass(ContextTruncatedEvent::class) subclass(ExecutionPlanLockedEvent::class) + subclass(ExecutionPlanRejectedEvent::class) subclass(JournalCompactedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ExecutionPlanRejectedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ExecutionPlanRejectedEventSerializationTest.kt new file mode 100644 index 00000000..fa64cd71 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ExecutionPlanRejectedEventSerializationTest.kt @@ -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) + } +}