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:
@@ -13,3 +13,10 @@ test-all = "./gradlew check --rerun-tasks"
|
|||||||
test-module = "./gradlew :core:<module>:test --rerun-tasks"
|
test-module = "./gradlew :core:<module>:test --rerun-tasks"
|
||||||
context-lookup = "python scripts/ctx.py <query>"
|
context-lookup = "python scripts/ctx.py <query>"
|
||||||
epic-status = "bash scripts/epic-status.sh"
|
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"
|
||||||
|
|||||||
@@ -81,3 +81,6 @@ apps/server/logs/
|
|||||||
|
|
||||||
# local QA scratch workspace (nested git repo)
|
# local QA scratch workspace (nested git repo)
|
||||||
/qa/
|
/qa/
|
||||||
|
|
||||||
|
# QA scaffold artifact (freestyle build-gate runs)
|
||||||
|
frontend/
|
||||||
|
|||||||
+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.EvaluationContext
|
||||||
import com.correx.core.transitions.evaluation.PromptResolver
|
import com.correx.core.transitions.evaluation.PromptResolver
|
||||||
import com.correx.core.transitions.execution.StageExecutionResult
|
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.StageConfig
|
||||||
import com.correx.core.transitions.graph.WorkflowGraph
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
import com.correx.core.transitions.resolution.TransitionDecision
|
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_SUMMARY_CAP = 2_000
|
||||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_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(
|
@SuppressWarnings(
|
||||||
"ForbiddenComment",
|
"ForbiddenComment",
|
||||||
"UnusedParameter",
|
"UnusedParameter",
|
||||||
@@ -2227,6 +2233,31 @@ abstract class SessionOrchestrator(
|
|||||||
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
|
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
|
||||||
* under them. Pure projection, replay-safe.
|
* 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> {
|
private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
|
||||||
val events = eventStore.read(sessionId)
|
val events = eventStore.read(sessionId)
|
||||||
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||||
@@ -2313,16 +2344,26 @@ abstract class SessionOrchestrator(
|
|||||||
effectives: RunEffectives,
|
effectives: RunEffectives,
|
||||||
profileCommands: Map<String, String>,
|
profileCommands: Map<String, String>,
|
||||||
): StageExecutionResult {
|
): 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 runner = staticAnalysisRunner
|
||||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||||
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
|
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
|
||||||
val command = profileCommands[alias]
|
val command = profileCommands[alias]
|
||||||
if (command.isNullOrBlank()) {
|
if (command.isNullOrBlank()) {
|
||||||
log.warn(
|
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",
|
"command — skipping execution gate",
|
||||||
stageId.value, stageConfig.buildExpectation, alias,
|
stageId.value, expectation, alias,
|
||||||
)
|
)
|
||||||
return StageExecutionResult.Success(emptyList())
|
return StageExecutionResult.Success(emptyList())
|
||||||
}
|
}
|
||||||
@@ -2334,10 +2375,10 @@ abstract class SessionOrchestrator(
|
|||||||
|
|
||||||
log.warn(
|
log.warn(
|
||||||
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
|
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
|
||||||
sessionId.value, stageId.value, stageConfig.buildExpectation, command,
|
sessionId.value, stageId.value, expectation, command,
|
||||||
)
|
)
|
||||||
return StageExecutionResult.Failure(
|
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" +
|
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
|
||||||
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
||||||
retryable = true,
|
retryable = true,
|
||||||
|
|||||||
@@ -43,5 +43,13 @@ data class StageConfig(
|
|||||||
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
|
// 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.
|
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
|
||||||
val semanticReview: Boolean = false,
|
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(),
|
val metadata: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
+32
-6
@@ -44,6 +44,31 @@ class ExecutionPlanCompiler(
|
|||||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||||
validateTools(plan)
|
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 ->
|
val stageMap = plan.stages.associate { s ->
|
||||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
// `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.
|
// registered artifact kind. Plans predating the split used one string for both.
|
||||||
@@ -59,11 +84,6 @@ class ExecutionPlanCompiler(
|
|||||||
staticManifest = emptyList(),
|
staticManifest = emptyList(),
|
||||||
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||||
).sorted()
|
).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(
|
StageId(s.id) to StageConfig(
|
||||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
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,
|
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||||
// not existence guarantees, so they are excluded here.
|
// not existence guarantees, so they are excluded here.
|
||||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
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,
|
semanticReview = s.semanticReview,
|
||||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||||
metadata = mapOf("promptInline" to s.prompt),
|
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 isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
||||||
|
|
||||||
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
|
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
|
||||||
|
|||||||
+89
@@ -4,6 +4,7 @@ import com.correx.core.artifacts.kind.ConfigArtifactKind
|
|||||||
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
||||||
import com.correx.core.artifacts.kind.JsonSchema
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
import com.correx.core.artifacts.kind.JsonSchemaProperty
|
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.Test
|
||||||
import org.junit.jupiter.api.assertThrows
|
import org.junit.jupiter.api.assertThrows
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -362,4 +363,92 @@ class ExecutionPlanCompilerTest {
|
|||||||
)
|
)
|
||||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "wf") }
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user