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:
2026-06-15 00:26:22 +04:00
parent 618f6e91c0
commit 68136e77d7
7 changed files with 494 additions and 0 deletions
@@ -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")