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
@@ -137,6 +137,7 @@ import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.BuildExpectation
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
@@ -237,6 +238,11 @@ private const val REVIEW_OBJECTIVE_CAP = 4_000
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
// KindInference kinds for build manifests — a written file of one of these declares a buildable
// project toolchain even before any source lands, so the auto build gate fires (see
// SessionOrchestrator.sessionProducedBuildTarget). Kept in sync with KindInference's manifest kinds.
private val BUILD_MANIFEST_KINDS = setOf("package_json", "gradle_module")
@SuppressWarnings(
"ForbiddenComment",
"UnusedParameter",
@@ -2227,6 +2233,31 @@ abstract class SessionOrchestrator(
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
* under them. Pure projection, replay-safe.
*/
/**
* True if the session has committed to a buildable project — it wrote either a real code module
* (a path whose inferred kind carries the `imports_resolve` COMPILER assertion: TS/React/Kotlin
* source) OR a build manifest ([BUILD_MANIFEST_KINDS]: package.json, a Gradle module) that
* declares a toolchain. Read from the recorded FileWritten manifest (invariant #9: replay-safe,
* no filesystem access), it is the run-time signal that warrants the auto build gate — the
* planner's declared kinds (uniformly `file_written`) never reveal this, but the written paths do.
*
* The manifest case is what catches the degenerate scaffold that writes only `package.json` and
* no sources: `npm run build` then fails for lack of an entry, so the gate rejects the empty
* "project" instead of letting it reach COMPLETED. A docs-only plan writes neither, so it is left
* alone.
*/
private fun sessionProducedBuildTarget(sessionId: SessionId): Boolean =
eventStore.read(sessionId)
.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.postImageHash != null }
.map { it.path }
.distinct()
.any { path ->
val kind = KindInference.kindFor(path) ?: return@any false
kind in BUILD_MANIFEST_KINDS ||
KindContractTable.assertionsFor(kind, path).any { it.id == "imports_resolve" }
}
private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
val events = eventStore.read(sessionId)
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
@@ -2313,16 +2344,26 @@ abstract class SessionOrchestrator(
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val alias = stageConfig.buildExpectation.commandAlias ?: return StageExecutionResult.Success(emptyList())
// An explicit build_expectation resolves directly. Otherwise, if the compiler flagged this
// (terminal) stage as auto-gateable and the session actually wrote a code module, promote to a
// PROJECT build — the LLM planner never sets build_expectation on its file_written stages, so
// this is the only path that gates a scaffold's compilability. Code-ness is judged here (not at
// compile time) from the real FileWritten manifest, so a docs-only plan is left alone.
val expectation: BuildExpectation? = when {
stageConfig.buildExpectation != BuildExpectation.NONE -> stageConfig.buildExpectation
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
else -> null
}
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val command = profileCommands[alias]
if (command.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage {} declares build_expectation={} but project profile has no '{}' " +
"[Orchestrator] stage {} needs a {} build gate but project profile has no '{}' " +
"command — skipping execution gate",
stageId.value, stageConfig.buildExpectation, alias,
stageId.value, expectation, alias,
)
return StageExecutionResult.Success(emptyList())
}
@@ -2334,10 +2375,10 @@ abstract class SessionOrchestrator(
log.warn(
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
sessionId.value, stageId.value, stageConfig.buildExpectation, command,
sessionId.value, stageId.value, expectation, command,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not pass its ${stageConfig.buildExpectation} build gate. " +
"stage ${stageId.value} did not pass its $expectation build gate. " +
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
retryable = true,
@@ -43,5 +43,13 @@ data class StageConfig(
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
val semanticReview: Boolean = false,
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's
// terminal stage when no stage declares an explicit [buildExpectation]. 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 plan that scaffolds real code would otherwise never hit an execution gate.
// When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the
// session actually wrote a code module (decided at run time from the FileWritten manifest + path→kind
// inference), so a docs-only plan is left untouched. Explicit build_expectation always wins.
val autoBuildGate: Boolean = false,
val metadata: Map<String, String> = emptyMap(),
)