feat(workflow): deterministic plan lint (plan-pipeline §5, Slice 1)
First slice of the plan-generation pipeline epic. A pure-Kotlin PlanLinter runs over the compiled freestyle ExecutionPlan (WorkflowGraph) before it is locked, complementing ExecutionPlanCompiler (which throws on unreachable/unknown-kind/bad-edge) with the checks it does NOT make: - HARD unproduced_need — a stage needs an artifact no stage produces. Real gap: only the TOML loader checked this; the freestyle compiler did not, so such plans compiled and failed at runtime. - HARD trap_state — a stage with no path to the terminal (inescapable loop / dead end). Uses terminal-reachability, NOT "any cycle", so legitimate verdict-gated review loops pass. - SOFT stage_count / fan_out / empty_brief / duplicate_brief — scored, never blocking. FreestyleDriver lints after compile and records PlanLintCompletedEvent (pure function of the recorded plan → replay-safe, no env observation in v1); a hard failure rejects the plan (ExecutionPlanRejectedEvent source="lint") before the operator is asked or the plan locks, so a broken plan never executes. Pays off now for single-plan freestyle; becomes the per-candidate filter when multi-candidate generation (Slice 2) lands. Deferred to later slices: file-path grounding + symbol resolution (needs an index), token-budget/ADR-bypass checks, and a dedicated :core:planning module.
This commit is contained in:
@@ -4,6 +4,9 @@ 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.events.PlanLintCompletedEvent
|
||||
import com.correx.infrastructure.workflow.PlanLintResult
|
||||
import com.correx.infrastructure.workflow.PlanLinter
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -43,6 +46,19 @@ class FreestyleDriver(
|
||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||
return
|
||||
}
|
||||
// Deterministic lint (plan-pipeline-spec §5) before the plan is surfaced/locked: a hard
|
||||
// failure (unproduced need, trap state) means the plan would fail at runtime, so reject it
|
||||
// now rather than execute a broken plan. Soft findings are recorded for display only.
|
||||
val lint = PlanLinter.lint(graph)
|
||||
emitPlanLint(sessionId, graph.id, lint)
|
||||
if (!lint.passed) {
|
||||
val summary = lint.hardFailures.joinToString("; ") {
|
||||
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||
}
|
||||
log.warn("freestyle: plan failed lint for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
return
|
||||
}
|
||||
val approved = requestPlanApproval(sessionId, json)
|
||||
if (!approved) {
|
||||
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
|
||||
@@ -83,6 +99,28 @@ class FreestyleDriver(
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun emitPlanLint(sessionId: SessionId, candidateId: String, lint: PlanLintResult) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = PlanLintCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
hardFailures = lint.hardFailures,
|
||||
softFindings = lint.softFindings,
|
||||
score = lint.score,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
@@ -13,6 +14,7 @@ import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -54,6 +56,22 @@ class FreestyleDriverTest {
|
||||
|
||||
private val malformedPlanJson = """{ "not": "a valid plan" }"""
|
||||
|
||||
// Compiles cleanly, but "apply" needs "ghost", which no stage produces — a lint hard failure
|
||||
// (the compiler does not check needs satisfaction).
|
||||
private val lintFailingPlanJson = """
|
||||
{
|
||||
"goal": "broken plan",
|
||||
"stages": [
|
||||
{ "id": "analyse", "prompt": "Analyse", "produces": "patch", "needs": [], "tools": [] },
|
||||
{ "id": "apply", "prompt": "Apply", "produces": "patch", "needs": ["ghost"], "tools": [] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "analyse", "to": "apply", "condition": { "type": "always_true" } },
|
||||
{ "from": "apply", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun buildRegistry(): DefaultArtifactKindRegistry =
|
||||
DefaultArtifactKindRegistry().also {
|
||||
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
|
||||
@@ -205,6 +223,63 @@ class FreestyleDriverTest {
|
||||
assertEquals("operator", rejectedEvents.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan failing lint is rejected with source lint and never locks or runs phase2`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-lint-fail-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
|
||||
var runPhase2Invocations = 0
|
||||
var approvalRequested = false
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
planContent = { lintFailingPlanJson },
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { _, _, _ ->
|
||||
runPhase2Invocations++
|
||||
WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done"))
|
||||
},
|
||||
requestPlanApproval = { _, _ -> approvalRequested = true; true },
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
assertTrue(payloads.filterIsInstance<ExecutionPlanLockedEvent>().isEmpty(), "broken plan must not lock")
|
||||
assertEquals(0, runPhase2Invocations, "runPhase2 must not run on a lint failure")
|
||||
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
|
||||
|
||||
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}")
|
||||
|
||||
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
|
||||
assertEquals("lint", rejected.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clean plan records a passing PlanLintCompletedEvent before locking`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-lint-clean-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
planContent = { validPlanJson },
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { sid, graph, _ -> WorkflowResult.Completed(sid, graph.start) },
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||
assertTrue(lint.hardFailures.isEmpty(), "clean plan should have no hard failures: ${lint.hardFailures}")
|
||||
assertEquals(1, payloads.filterIsInstance<ExecutionPlanLockedEvent>().size, "clean plan should lock")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-operator-approve-session")
|
||||
|
||||
Reference in New Issue
Block a user