diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt index 9a005d59..9f9969ee 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt @@ -109,6 +109,18 @@ internal suspend fun SessionOrchestrator.runLspDiagnostics( ) } +/** + * True if the most recent LSP diagnostics run for [stageId] was skipped (e.g. tsserver failed to + * initialize) rather than actually run. A skipped run emits empty diagnostics, which the LSP gate + * treats as clean — so the execution gate must not trust MODULE→LSP delegation when this holds, and + * falls through to the real build command. Pure projection over recorded events (invariant #9). + */ +internal fun SessionOrchestrator.lspDiagnosticsSkipped(sessionId: SessionId, stageId: StageId): Boolean = + eventStore.read(sessionId) + .mapNotNull { it.payload as? LspDiagnosticsCompletedEvent } + .lastOrNull { it.stageId == stageId } + ?.skippedReason != null + internal fun SessionOrchestrator.sessionWrittenPaths(sessionId: SessionId): List = eventStore.read(sessionId) .mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent } @@ -145,8 +157,15 @@ internal suspend fun SessionOrchestrator.runExecutionGate( else -> null } // LSP pull diagnostics are the compiler-front-end/typecheck gate. Keep PROJECT bundlers and - // TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well. - if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null) { + // TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well — + // UNLESS the LSP run for this stage was skipped (no tsserver, init failure): a skipped check + // verified nothing, and empty diagnostics then read as "clean", so fall through to the real + // build command instead of trusting a gate that never ran. + // ponytail: no unit test — first test on this path needs a full orchestrator + bound-profile + + // LSP-runner fixture (none exists); validated by live QA re-run instead. Add if it regresses. + if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null && + !lspDiagnosticsSkipped(sessionId, stageId) + ) { return StageExecutionResult.Success(emptyList()) } val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList()) 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 1f3a0f1a..f0fe4aa7 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 @@ -114,23 +114,7 @@ class ExecutionPlanCompiler( ) ) } - // 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 autoGateStages: Set = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) { - val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet() - if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan) - } else { - emptySet() - } + val autoGateStages = autoGateStages(plan, declaredExpectations) val sessionArtifacts = plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds @@ -325,6 +309,31 @@ class ExecutionPlanCompiler( "cite the unmet criterion ids. Never add or imply criteria outside the DoD." } + // 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: unless a stage declares a real whole-project build (PROJECT/TESTS), + // flag every write-declaring stage plus the terminal stage for an auto build gate — the terminal + // one runs when the project is whole rather than failing legitimately-incomplete intermediate + // stages. A MODULE declaration is only a per-file typecheck (delegated to LSP, which can silently + // skip) and does NOT prove the assembled project builds, so it must not suppress the terminal + // floor. Whether it ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) + // from the real FileWritten manifest — declared kinds don't reveal code-ness, written paths do — + // so a docs-only plan is left alone and a code plan is always gated. + private fun autoGateStages( + plan: ExecutionPlanModel, + declaredExpectations: Map, + ): Set { + val ownsRealBuild = declaredExpectations.values.any { + it == BuildExpectation.PROJECT || it == BuildExpectation.TESTS + } + if (ownsRealBuild) return emptySet() + val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet() + return if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan) + } + private fun staticFloorCommands(writeManifest: List): List { return writeManifest.takeIf { it.isNotEmpty() } ?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") } 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 ee6d029c..c927dd68 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 @@ -559,16 +559,18 @@ class ExecutionPlanCompilerTest { } @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. + fun `a MODULE declaration does not suppress the auto build gate`() { + // MODULE is only a per-file typecheck (delegated to LSP, which can silently skip when + // tsserver is missing), so it does NOT prove the assembled project builds — the terminal + // whole-project floor must still be applied. 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" }, + "needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"], "build_expectation": "module" }, { "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component", - "needs": ["app_entry"], "tools": ["file_write"] } + "needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] } ], "edges": [ { "from": "entry", "to": "views", "condition": { "type": "always_true" } }, @@ -581,9 +583,39 @@ class ExecutionPlanCompilerTest { com.correx.core.transitions.graph.BuildExpectation.MODULE, graph.stages[StageId("entry")]!!.buildExpectation, ) + assertTrue( + graph.stages.values.any { it.autoBuildGate }, + "MODULE (typecheck-only) must not remove the terminal project build floor", + ) + } + + @Test + fun `an explicitly declared PROJECT gate suppresses the auto gate`() { + // A PROJECT build is a real whole-project build the planner owns, so the compiler must + // not add its own terminal floor on top. + val planned = """ + { + "goal": "scaffold a react app", + "stages": [ + { "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry", + "needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"], "build_expectation": "project" }, + { "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component", + "needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] } + ], + "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.PROJECT, + 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", + "a real PROJECT build declared anywhere suppresses the auto gate", ) }