diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index c6877a6e..6b2236d5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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) }, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/inference/SemanticReviewerImpl.kt b/apps/server/src/main/kotlin/com/correx/apps/server/inference/SemanticReviewerImpl.kt new file mode 100644 index 00000000..b2a26e41 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/inference/SemanticReviewerImpl.kt @@ -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, + 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 { + 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 = 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 } + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt index 1ac69f0a..4f261326 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt @@ -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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ReviewFindingEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ReviewFindingEvents.kt new file mode 100644 index 00000000..7200ae85 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ReviewFindingEvents.kt @@ -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, + /** True when this review's FAIL verdict blocked the stage (fed back for a fix); false = advisory. */ + val blocked: Boolean = false, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index abc3beb9..097246f4 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanCompileCheckedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanCompileCheckedEventSerializationTest.kt new file mode 100644 index 00000000..afe142ad --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanCompileCheckedEventSerializationTest.kt @@ -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) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ReviewFindingsRaisedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ReviewFindingsRaisedEventSerializationTest.kt new file mode 100644 index 00000000..d8d91692 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ReviewFindingsRaisedEventSerializationTest.kt @@ -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) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt index d44bd503..16c2a9fa 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt @@ -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, ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/PlanCompilationCheck.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/PlanCompilationCheck.kt new file mode 100644 index 00000000..4ecdf609 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/PlanCompilationCheck.kt @@ -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? +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SemanticReviewer.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SemanticReviewer.kt new file mode 100644 index 00000000..d3bb7467 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SemanticReviewer.kt @@ -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, +) + +/** + * 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, + objective: String, + ): ReviewOutcome +} 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 7626d9c4..a06bd4cb 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 @@ -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, 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 ff1fc61f..ef189380 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 @@ -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 = 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 = 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 858f8a4d..3f2d2cda 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 @@ -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), ) diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index 9691595d..34479443 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -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( diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 21c94f6b..4e848825 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -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