feat(validation): plan-compile gate + semantic-review gate (staged verification #19/#20)

Plan-compile gate (#19): PlanCompilationCheck seam (null-on-replay),
runPlanCompileGate guards a produced execution_plan slot and compiles its
cached content via ExecutionPlanCompiler, emitting PlanCompileCheckedEvent.
On failure the architect gets a retryable hand-back and self-corrects;
exhaustion terminates the dead-end workflow instead of shipping an
uncompilable plan.

Semantic-review gate (#20): SemanticReviewer seam + StageConfig.semanticReview
opt-in (TOML semantic_review + freestyle plan JSON). runReviewGate reads the
FileWritten manifest (never blind context), asks a review-capable model for
PR-comment findings, and is advisory — blocks retryably only on FAIL plus a
correctness finding >=0.7 confidence, capped at 2 blocks so a stubborn stage
completes advisory-only rather than trapping. SemanticReviewerImpl talks to
the provider directly; any reviewer error degrades to PASS. REVIEW_MAX_TOKENS
is 8192: gemma is a reasoning model and 1024 truncated it mid-reasoning before
it reached the JSON verdict (empty content -> parse degraded to PASS).

Both gates live-QA'd on the real repo: plan-compile passed on a freestyle
web-UI scaffold; semantic review caught all 3 planted bugs in a maxOf() and
exercised the block->retry->cap safety valve end-to-end.
This commit is contained in:
2026-07-06 13:11:16 +04:00
parent 28e9e698a1
commit 9f12c87bb1
15 changed files with 547 additions and 2 deletions
@@ -71,6 +71,7 @@ import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.SemanticReviewerImpl
import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService
import com.correx.apps.server.personalization.ProfileAdaptationService
@@ -326,6 +327,7 @@ fun main() {
workspaceToolRegistryProvider = wsToolRegistryProvider,
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
contractAssertionEvaluator = FileSystemContractEvaluator(),
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
)
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
val defaultOrchestrationConfig = OrchestrationConfig(
@@ -366,9 +368,17 @@ fun main() {
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
taskKnowledgeRetriever.delegate =
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
// Plan-compile gate: wrap the ExecutionPlanCompiler as a post-stage check so a compile-invalid
// architect plan is handed back through the retry-feedback loop instead of parking the session in
// ACTIVE (post-planning compile dead-end). Returns null on success, the compiler message on failure.
val planCompiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet())
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
engines = engines.copy(
planCompilationCheck = { json, candidateId ->
runCatching { planCompiler.compile(json, candidateId) }.exceptionOrNull()?.message
},
),
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = firstProvider.tokenizer,
@@ -491,7 +501,7 @@ fun main() {
val freestyleDriver = FreestyleDriver(
eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
compiler = planCompiler,
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
@@ -0,0 +1,188 @@
package com.correx.apps.server.inference
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat
import com.correx.core.kernel.orchestration.ReviewOutcome
import com.correx.core.kernel.orchestration.SemanticReviewer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.util.UUID
import kotlin.io.path.readText
private const val MAX_FILE_CHARS = 8_000
private const val MAX_TOTAL_CHARS = 24_000
// Reviewer runs on a reasoning-capable model (gemma) that spends a large token budget thinking
// before it emits the final JSON verdict. 1024 truncated it mid-reasoning → empty content →
// parse degraded to PASS. Give it real headroom so it reaches the JSON.
private const val REVIEW_MAX_TOKENS = 8_192
/**
* Gate 3 reviewer implementation: reads the stage's actually-produced files from the workspace (the
* FileWritten manifest — never blind context), asks a review-capable model for PR-comment-style
* findings as JSON, and maps them to [ReviewOutcome]. Talks to the provider directly with a
* self-contained prompt (like [summarizeWithInference]) rather than the orchestrator's contextPack
* machinery. Any failure (unroutable, timeout, unparseable output) degrades to a PASS with no
* findings — the reviewer is advisory and must never wedge a stage on its own error.
*/
class SemanticReviewerImpl(
private val inferenceRouter: InferenceRouter,
private val modelId: String? = null,
) : SemanticReviewer {
override suspend fun review(
sessionId: SessionId,
stageId: StageId,
workspaceRoot: Path,
files: List<String>,
objective: String,
): ReviewOutcome {
val bundle = renderFiles(workspaceRoot, files)
if (bundle.isBlank()) return ReviewOutcome(ReviewVerdict.PASS, emptyList())
return runCatching {
val provider = inferenceRouter.route(stageId, setOf(ModelCapability.General), modelId)
val prompt = buildPrompt(objective, bundle)
val request = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
contextPack = pack(sessionId, stageId, prompt),
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = REVIEW_MAX_TOKENS),
responseFormat = ResponseFormat.Text,
)
parse(provider.infer(request).text)
}.getOrElse {
log.warn(
"[SemanticReviewer] review failed session={} stage={}: {}",
sessionId.value, stageId.value, it.message,
)
ReviewOutcome(ReviewVerdict.PASS, emptyList())
}
}
private fun renderFiles(workspaceRoot: Path, files: List<String>): String {
val sb = StringBuilder()
for (rel in files) {
if (sb.length >= MAX_TOTAL_CHARS) break
val text = runCatching { workspaceRoot.resolve(rel).readText() }.getOrNull()
if (text != null) {
sb.append("=== FILE: ").append(rel).append(" ===\n")
.append(text.take(MAX_FILE_CHARS)).append("\n\n")
}
}
return sb.toString().take(MAX_TOTAL_CHARS)
}
private fun buildPrompt(objective: String, bundle: String): String =
"""
You are a senior code reviewer performing a PR review. The deterministic checks (compile,
contract, build) have ALREADY passed — do NOT report anything a compiler or linter would catch.
Review ONLY for correctness bugs, logic errors, security issues, and clear design/smell problems.
STAGE OBJECTIVE:
$objective
FILES PRODUCED:
$bundle
Respond with ONLY a JSON object, no prose, no markdown fence:
{"verdict":"PASS|WARN|FAIL","findings":[
{"severity":"CRITICAL|MAJOR|MINOR|NIT","confidence":0.0-1.0,"category":"correctness|smell|architecture|naming|security",
"target":"path/to/file.ext","message":"what is wrong","suggestedFix":"how to fix (optional)","correctness":true|false}
]}
Set correctness=true only when the code is actually WRONG (a confirmable bug), false for
subjective/suboptimal opinions. Use verdict FAIL only when at least one correctness finding is
high-confidence. Empty findings + PASS when the code is sound.
""".trimIndent()
private fun pack(sessionId: SessionId, stageId: StageId, prompt: String) = ContextPack(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L0 to listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = prompt,
sourceType = "semantic_review",
sourceId = "reviewer",
tokenEstimate = prompt.length / 4,
role = EntryRole.USER,
),
),
),
budgetUsed = prompt.length / 4,
budgetLimit = prompt.length / 4,
compressionMetadata = CompressionMetadata(),
)
private fun parse(raw: String): ReviewOutcome {
val pass = ReviewOutcome(ReviewVerdict.PASS, emptyList())
val dto = extractJson(raw)
?.let { json -> runCatching { lenient.decodeFromString(ReviewDto.serializer(), json) }.getOrNull() }
?: return pass
val verdict = runCatching { ReviewVerdict.valueOf(dto.verdict.uppercase()) }.getOrDefault(ReviewVerdict.PASS)
val findings = dto.findings.map { f ->
ReviewFinding(
severity = runCatching { ReviewSeverity.valueOf(f.severity.uppercase()) }
.getOrDefault(ReviewSeverity.MINOR),
confidence = f.confidence.coerceIn(0.0, 1.0),
category = f.category.ifBlank { "correctness" },
target = f.target,
message = f.message,
suggestedFix = f.suggestedFix?.takeIf { it.isNotBlank() },
correctness = f.correctness,
)
}
return ReviewOutcome(verdict, findings)
}
/** Strip an optional ```json fence and slice from the first { to the last } — tolerant of prose wrap. */
private fun extractJson(raw: String): String? {
val start = raw.indexOf('{')
val end = raw.lastIndexOf('}')
return if (start in 0 until end) raw.substring(start, end + 1) else null
}
@Serializable
private data class ReviewDto(
val verdict: String = "PASS",
val findings: List<FindingDto> = emptyList(),
)
@Serializable
private data class FindingDto(
val severity: String = "MINOR",
val confidence: Double = 0.5,
val category: String = "correctness",
val target: String = "",
val message: String = "",
@SerialName("suggestedFix") val suggestedFix: String? = null,
val correctness: Boolean = false,
)
companion object {
private val log = LoggerFactory.getLogger(SemanticReviewerImpl::class.java)
private val lenient = Json { ignoreUnknownKeys = true; isLenient = true }
}
}
@@ -2,6 +2,7 @@ package com.correx.core.events.events
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -34,3 +35,20 @@ data class ExecutionPlanRejectedEvent(
/** "compile" | "missing_content" | "operator" */
val source: String,
) : EventPayload
/**
* Environment observation (invariant #9): the outcome of compiling a stage's produced execution_plan
* as a post-stage gate. Recorded at the moment the architect stage completes so replay reads the
* recorded verdict and never re-compiles. A non-null [error] fails the producing stage retryably,
* feeding the compiler message back so the architect self-corrects via the retry-feedback loop
* (rather than parking the session in ACTIVE forever, as a post-planning compile failure did).
*/
@Serializable
@SerialName("PlanCompileChecked")
data class PlanCompileCheckedEvent(
val sessionId: SessionId,
val stageId: StageId,
val passed: Boolean,
/** Compiler error when [passed] is false; null on success. Kept bounded. */
val error: String? = null,
) : EventPayload
@@ -0,0 +1,54 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Overall verdict of a semantic (LLM) review over a stage's produced files. */
enum class ReviewVerdict { PASS, WARN, FAIL }
/** Severity of a single review finding, high→low. */
enum class ReviewSeverity { CRITICAL, MAJOR, MINOR, NIT }
/**
* One PR-comment-style finding from the semantic reviewer (Gate 3). A finding is a proposal, not a
* fact — [confidence] is the reviewer's own 0..1 self-estimate and [correctness] marks findings that
* claim the code is *wrong* (deterministically re-confirmable) vs. merely *suboptimal* (a smell /
* style / architecture opinion that can never block on its own).
*/
@Serializable
data class ReviewFinding(
val severity: ReviewSeverity,
/** Reviewer self-estimated confidence, 0.0..1.0. */
val confidence: Double,
/** Short category slug, e.g. "correctness", "smell", "architecture", "naming". */
val category: String,
/** Workspace-relative file (optionally :line) the finding anchors to. */
val target: String,
/** What is wrong / suboptimal. Kept bounded by the producer. */
val message: String,
/** Suggested fix, when the reviewer offers one. */
val suggestedFix: String? = null,
/** True = claims the code is incorrect (confirmable); false = suboptimal opinion (never blocks). */
val correctness: Boolean = false,
)
/**
* Environment observation (invariant #9): the semantic reviewer's findings over a stage's produced
* files, captured at the moment the stage completed. The reviewer is advisory — it reads the actual
* FileWritten manifest (never blind context) and its findings surface to the operator regardless of
* verdict. A [ReviewVerdict.FAIL] carrying a high-confidence correctness finding can block the stage
* retryably, but only until the review-block retry budget is spent, after which findings surface
* without blocking. Replay reads these recorded facts and never re-runs the reviewer.
*/
@Serializable
@SerialName("ReviewFindingsRaised")
data class ReviewFindingsRaisedEvent(
val sessionId: SessionId,
val stageId: StageId,
val verdict: ReviewVerdict,
val findings: List<ReviewFinding>,
/** True when this review's FAIL verdict blocked the stage (fed back for a fix); false = advisory. */
val blocked: Boolean = false,
) : EventPayload
@@ -33,6 +33,8 @@ import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
@@ -164,6 +166,8 @@ val eventModule = SerializersModule {
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class)
subclass(PlanCompileCheckedEvent::class)
subclass(ReviewFindingsRaisedEvent::class)
subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class)
subclass(HealthRestoredEvent::class)
@@ -0,0 +1,38 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PlanCompileCheckedEventSerializationTest {
@Test
fun `PlanCompileCheckedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = PlanCompileCheckedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
passed = false,
error = "edge from unknown stage 'ghost'",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"PlanCompileChecked\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `PlanCompileCheckedEvent round-trips on success with null error`() {
val sample: EventPayload = PlanCompileCheckedEvent(
sessionId = SessionId("s"),
stageId = StageId("architect"),
passed = true,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,40 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReviewFindingsRaisedEventSerializationTest {
@Test
fun `ReviewFindingsRaisedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ReviewFindingsRaisedEvent(
sessionId = SessionId("s"),
stageId = StageId("implement"),
verdict = ReviewVerdict.FAIL,
findings = listOf(
ReviewFinding(
severity = ReviewSeverity.CRITICAL,
confidence = 0.9,
category = "correctness",
target = "src/App.tsx:42",
message = "off-by-one in the loop bound",
suggestedFix = "use < instead of <=",
correctness = true,
),
),
blocked = true,
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ReviewFindingsRaised\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -34,4 +34,13 @@ data class OrchestratorEngines(
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
// Compiles a stage's produced execution_plan as a post-stage gate (plan-compile gate). Null = no
// gate; a compile-invalid plan then falls through to the post-planning compiler, which historically
// parked the session in ACTIVE. Wired, it fails the architect stage retryably so the existing
// retry-feedback loop hands the compiler error back for self-correction.
val planCompilationCheck: PlanCompilationCheck? = null,
// Gate 3 semantic (LLM) reviewer, run last over a stage's static-clean produced files (design §
// Gate 3). Null = no semantic review; the funnel then rests on the deterministic gates only. A
// stage opts in via StageConfig.semanticReview.
val semanticReviewer: SemanticReviewer? = null,
)
@@ -0,0 +1,17 @@
package com.correx.core.kernel.orchestration
/**
* Seam for compiling a freestyle execution_plan as a post-stage gate. Like [ContractAssertionEvaluator]
* and [StaticAnalysisRunner], the real implementation (which wraps the infrastructure
* ExecutionPlanCompiler) is injected, so the deterministic core never depends on infrastructure and
* the gate stays unit-testable with a fake.
*
* [check] returns null when the plan compiles, or a bounded compiler-error string when it does not.
* The orchestrator records the verdict in a
* [com.correx.core.events.events.PlanCompileCheckedEvent] (invariant #9), and on failure fails the
* architect stage retryably so the existing retry-feedback loop hands the error back and the model
* self-corrects — replacing the post-planning compile failure that parked the session in ACTIVE.
*/
fun interface PlanCompilationCheck {
fun check(planJson: String, candidateId: String): String?
}
@@ -0,0 +1,35 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import java.nio.file.Path
/** Result of a semantic review over a stage's produced files. */
data class ReviewOutcome(
val verdict: ReviewVerdict,
val findings: List<ReviewFinding>,
)
/**
* Seam for the Gate 3 semantic (LLM) reviewer. Like the other gate seams
* ([ContractAssertionEvaluator], [StaticAnalysisRunner], [PlanCompilationCheck]), the
* inference-and-filesystem-touching implementation lives in apps/infrastructure and is injected, so
* the deterministic core never runs inference or reads files itself and the gate stays
* unit-testable with a fake.
*
* The implementation reads the actual produced [files] from [workspaceRoot] (the FileWritten
* manifest — never blind context), reviews them against [objective], and returns PR-comment-style
* findings. Runs last, after the deterministic funnel (contract/static/execution) has passed, so its
* input is static-clean and its findings exclude anything the cheap gates already catch.
*/
fun interface SemanticReviewer {
suspend fun review(
sessionId: SessionId,
stageId: StageId,
workspaceRoot: Path,
files: List<String>,
objective: String,
): ReviewOutcome
}
@@ -35,6 +35,9 @@ import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
@@ -220,6 +223,13 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
private const val CONTRACT_EVIDENCE_CAP = 400
// Gate 3 semantic review: a FAIL blocks only on a correctness finding at/above this confidence, and
// only until this many prior blocks have occurred for the stage — after which findings surface
// without blocking, so a stuck reviewer can never trap a stage. Objective text fed to the reviewer
// is capped to keep the review prompt bounded.
private const val REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val REVIEW_BLOCK_RETRY_CAP = 2
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
@@ -258,6 +268,8 @@ abstract class SessionOrchestrator(
private val worldProbe: WorldProbe = engines.worldProbe
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
private val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
private val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
@@ -2093,8 +2105,10 @@ abstract class SessionOrchestrator(
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
)
for (gate in gates) {
val result = gate()
@@ -2163,6 +2177,41 @@ abstract class SessionOrchestrator(
)
}
/**
* Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
* architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
* stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
* to the architect and the model self-corrects — rather than the post-planning compiler rejecting
* a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
* recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
* re-compiles.
*/
private suspend fun runPlanCompileGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
?: return StageExecutionResult.Success(emptyList())
val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
?: return StageExecutionResult.Success(emptyList())
val error = check.check(json, "freestyle-${sessionId.value}")
emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
if (error == null) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] plan-compile gate failed session={} stage={}: {}",
sessionId.value, stageId.value, error,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} produced an execution_plan that does not compile. " +
"Fix the plan before it can be locked:\n$error",
retryable = true,
)
}
/**
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
@@ -2284,6 +2333,78 @@ abstract class SessionOrchestrator(
)
}
/**
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
* (the FileWritten manifest — never blind context) and reviews them against the stage objective,
* returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
* funnel already passed) and its findings exclude anything the cheap gates catch.
*
* The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
* surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
* retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
* budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
* stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
* block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
*/
private suspend fun runReviewGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
val files = stageWrittenPaths(sessionId, stageId)
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
val objective = stageConfig.needs
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
.joinToString("\n\n")
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
.take(REVIEW_OBJECTIVE_CAP)
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
// A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
// budget remains — count prior review blocks for this stage from the log (replay-safe).
val priorBlocks = eventStore.read(sessionId)
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
.count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= REVIEW_BLOCK_MIN_CONFIDENCE
}
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null &&
priorBlocks < REVIEW_BLOCK_RETRY_CAP
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
if (!shouldBlock) {
if (outcome.findings.isNotEmpty()) {
log.info(
"[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
)
}
return StageExecutionResult.Success(emptyList())
}
log.warn(
"[Orchestrator] semantic review blocked session={} stage={} finding={}",
sessionId.value, stageId.value, blockingFinding?.target,
)
val detail = outcome.findings
.filter { it.correctness }
.joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
(it.suggestedFix?.let { fix -> "\n fix: $fix" } ?: "") }
return StageExecutionResult.Failure(
"stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
retryable = true,
)
}
private suspend fun emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
@@ -38,5 +38,10 @@ data class StageConfig(
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
val expectedFiles: List<String> = emptyList(),
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
// 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,
val metadata: Map<String, String> = emptyMap(),
)
@@ -74,6 +74,7 @@ class ExecutionPlanCompiler(
// not existence guarantees, so they are excluded here.
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
buildExpectation = buildExpectation,
semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
metadata = mapOf("promptInline" to s.prompt),
)
@@ -25,6 +25,9 @@ data class PlanStage(
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
// project profile's typecheck/build/test command run as a deterministic Gate 4.
@param:JsonProperty("build_expectation") val buildExpectation: String? = null,
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3).
// The planner emits this under the JSON key `semantic_review`; absent = off.
@param:JsonProperty("semantic_review") val semanticReview: Boolean = false,
)
data class PlanEdge(
@@ -51,6 +51,7 @@ private data class StageSection(
@param:JsonProperty("brief_echo") val briefEcho: Boolean = false,
@param:JsonProperty("require_task_decompose") val requireTaskDecompose: Boolean = false,
@param:JsonProperty("claim_task") val claimTask: Boolean = false,
@param:JsonProperty("semantic_review") val semanticReview: Boolean = false,
)
// condition fields flattened into the transition row
@@ -110,6 +111,7 @@ class TomlWorkflowLoader(
allowedTools = s.allowedTools.toSet(),
writeManifest = s.writes,
staticAnalysis = s.staticAnalysis,
semanticReview = s.semanticReview,
tokenBudget = s.tokenBudget,
// Propagate the declared token budget to the inference completion cap.
// Without this the StageConfig default (maxTokens=2048) is used, truncating