feat(validation): auto build-gate on terminal freestyle stage (Gate 4 floor)

Closes the COMPLETED-lie where a freestyle stage passes by producing a
schema-valid artifact regardless of whether its build actually succeeded:
a verify stage could run 'npm run build', watch it fail, then self-attest a
build_verified artifact and transition to done. Tool failures never failed
the stage.

The compiler now flags the terminal stage autoBuildGate=true when no stage
declares an explicit build_expectation. Code-ness is decided at runtime, not
compile time (the declared kind is always the generic file_written; only the
written paths reveal code): runExecutionGate folds the FileWritten manifest via
sessionProducedBuildTarget() and promotes to a PROJECT build when a build
manifest (package_json/gradle_module) or an imports_resolve-bearing path was
written, then runs the real command from .correx/project.toml [commands]. A
non-clean exit fails the stage retryably; a docs-only plan is left untouched.

Live-proven (run 3, session 28812b44): gate fired on the real
'npm --prefix frontend run build', exit 2 on a bad tsconfig, RetryAttempted
instead of a false COMPLETED.

Modules green: transitions+workflow 67, kernel 60.
This commit is contained in:
2026-07-07 01:01:12 +04:00
parent 79e2c38a88
commit 597a26d551
6 changed files with 185 additions and 11 deletions
@@ -44,6 +44,31 @@ class ExecutionPlanCompiler(
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
validateTools(plan)
// Parse + validate every stage's declared build_expectation up front — also feeds the
// deterministic build-gate guarantee below.
val declaredExpectations = plan.stages.associate { s ->
s.id to (
BuildExpectation.fromPlan(s.buildExpectation)
?: throw WorkflowValidationException(
"stage '${s.id}' has invalid build_expectation '${s.buildExpectation}' — " +
"use one of none|module|project|tests",
)
)
}
// Deterministic build-gate floor. Code kinds carry an `imports_resolve` COMPILER-layer
// contract assertion, but COMPILER assertions are enforced only by the execution gate, which
// fires only when a stage sets build_expectation. The LLM planner emits every file-writing
// stage as the generic `file_written` kind (never a typed code kind) and rarely sets
// build_expectation, so a scaffold with a dangling import (`import './index.css'` for a file
// never written) sails through to COMPLETED. Close it: when no stage declares any gate, flag
// the terminal stage for an auto build gate — placed there, not per-stage, so it runs when the
// project is whole rather than failing legitimately-incomplete intermediate stages. Whether it
// ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) from the real
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
// paths do — so a docs-only plan is left alone and a code plan is always gated.
val autoGateStageId: String? =
if (declaredExpectations.values.all { it == BuildExpectation.NONE }) terminalStageId(plan) else null
val stageMap = plan.stages.associate { s ->
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
// registered artifact kind. Plans predating the split used one string for both.
@@ -59,11 +84,6 @@ class ExecutionPlanCompiler(
staticManifest = emptyList(),
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
).sorted()
val buildExpectation = BuildExpectation.fromPlan(s.buildExpectation)
?: throw WorkflowValidationException(
"stage '${s.id}' has invalid build_expectation '${s.buildExpectation}' — " +
"use one of none|module|project|tests",
)
StageId(s.id) to StageConfig(
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
needs = s.needs.map { ArtifactId(it) }.toSet(),
@@ -73,7 +93,8 @@ class ExecutionPlanCompiler(
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
// not existence guarantees, so they are excluded here.
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
buildExpectation = buildExpectation,
buildExpectation = declaredExpectations.getValue(s.id),
autoBuildGate = s.id == autoGateStageId,
semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
metadata = mapOf("promptInline" to s.prompt),
@@ -146,6 +167,11 @@ class ExecutionPlanCompiler(
}
}
// The stage the plan ends on — the one whose edge points at "done" (a well-formed plan has exactly
// one). Falls back to the last declared stage if no terminal edge is present yet.
private fun terminalStageId(plan: ExecutionPlanModel): String =
plan.edges.firstOrNull { it.to == TERMINAL }?.from ?: plan.stages.last().id
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
@@ -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.artifacts.kind.JsonSchemaProperty
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
@@ -362,4 +363,92 @@ class ExecutionPlanCompilerTest {
)
assertThrows<WorkflowValidationException> { compiler.compile(bad, "wf") }
}
// --- deterministic build-gate guarantee -------------------------------------------------------
// Registry whose kinds resolve to code contracts (react_component / react_entry carry the
// `imports_resolve` COMPILER assertion in KindContractTable).
private val codeRegistry = DefaultArtifactKindRegistry().also {
it.register(ConfigArtifactKind(id = "react_component", schema = trivialSchema, llmEmitted = false))
it.register(ConfigArtifactKind(id = "react_entry", schema = trivialSchema, llmEmitted = false))
}
private val codeCompiler = ExecutionPlanCompiler(codeRegistry)
private val codePlanNoGate = """
{
"goal": "scaffold a react app",
"stages": [
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
"needs": [], "tools": ["file_write"] },
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
"needs": ["app_entry"], "tools": ["file_write"] }
],
"edges": [
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
{ "from": "views", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
@Test
fun `terminal stage of an ungated plan is flagged for an auto build gate`() {
val graph = codeCompiler.compile(codePlanNoGate, "wf")
val views = graph.stages[StageId("views")]!!
assertTrue(
views.autoBuildGate,
"terminal stage of an ungated plan must be flagged for an auto build gate",
)
assertEquals(
com.correx.core.transitions.graph.BuildExpectation.NONE,
views.buildExpectation,
"the flag alone — declared expectation stays NONE; the runtime promotes to PROJECT only if a " +
"code module was actually written",
)
assertTrue(
!graph.stages[StageId("entry")]!!.autoBuildGate,
"intermediate stages are not flagged — the gate runs when the project is whole, not per-stage",
)
}
@Test
fun `an explicitly declared gate suppresses the auto gate`() {
// Planner set a MODULE gate on the entry stage; the compiler must not add its own.
val planned = """
{
"goal": "scaffold a react app",
"stages": [
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
"needs": [], "tools": ["file_write"], "build_expectation": "module" },
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
"needs": ["app_entry"], "tools": ["file_write"] }
],
"edges": [
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
{ "from": "views", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = codeCompiler.compile(planned, "wf")
assertEquals(
com.correx.core.transitions.graph.BuildExpectation.MODULE,
graph.stages[StageId("entry")]!!.buildExpectation,
)
assertTrue(
graph.stages.values.none { it.autoBuildGate },
"no stage is auto-flagged when the planner already declares a gate anywhere",
)
}
@Test
fun `exactly the terminal stage of an ungated plan is auto-flagged`() {
// The compiler flags the terminal stage regardless of the produced kinds — code-ness is a
// run-time decision (SessionOrchestrator reads the FileWritten manifest), so a non-code plan
// is flagged too but the runtime build gate then skips it.
val graph = compiler.compile(validPlan, "wf")
assertEquals(
1,
graph.stages.values.count { it.autoBuildGate },
"exactly the single terminal stage of an ungated plan is flagged for the auto build gate",
)
}
}