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:
+46
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user