From 597a26d551bf35978c5477fcae42f4f382e07474 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 7 Jul 2026 01:01:12 +0400 Subject: [PATCH] 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. --- .correx/project.toml | 7 ++ .gitignore | 3 + .../orchestration/SessionOrchestrator.kt | 51 +++++++++-- .../core/transitions/graph/StageConfig.kt | 8 ++ .../workflow/ExecutionPlanCompiler.kt | 38 ++++++-- .../workflow/ExecutionPlanCompilerTest.kt | 89 +++++++++++++++++++ 6 files changed, 185 insertions(+), 11 deletions(-) diff --git a/.correx/project.toml b/.correx/project.toml index cec04e25..56d9af08 100644 --- a/.correx/project.toml +++ b/.correx/project.toml @@ -13,3 +13,10 @@ test-all = "./gradlew check --rerun-tasks" test-module = "./gradlew :core::test --rerun-tasks" context-lookup = "python scripts/ctx.py " epic-status = "bash scripts/epic-status.sh" +# Build-gate aliases (BuildExpectation → commandAlias): MODULE→typecheck, PROJECT→build, TESTS→test. +# The gate runner is whitespace-split / no-shell and runs at workspace_root (the repo), so `--prefix +# frontend` targets the Node subproject without a `cd`. `npm run build` = `tsc && vite build` (npm's +# own shell handles the &&), which fails on a dangling asset import — exactly the COMPLETED-lie hole. +typecheck = "npm --prefix frontend run build" +build = "npm --prefix frontend run build" +test = "npm --prefix frontend test" diff --git a/.gitignore b/.gitignore index a9896119..01c43528 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ apps/server/logs/ # local QA scratch workspace (nested git repo) /qa/ + +# QA scaffold artifact (freestyle build-gate runs) +frontend/ diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index ab977122..09b718a1 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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 { val events = eventStore.read(sessionId) val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent } @@ -2313,16 +2344,26 @@ abstract class SessionOrchestrator( effectives: RunEffectives, profileCommands: Map, ): 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, diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt index ef189380..28877963 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -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 = emptyMap(), ) diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 3f2d2cda..7857b420 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -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, transitions: Set, start: StageId) { diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index e01f0b66..d6ce2d9a 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -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 { 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", + ) + } }