feat(kernel): static-first reviewer gate (role-reliability §5)
Run operator-configured static-analysis commands (compiler/detekt/formatters) as a
deterministic harness step on the producing stage, before the LLM reviewer. A stage
declares `static_analysis = [commands]`; after it produces its artifact, each command
runs in the workspace root and the results are recorded in a StaticAnalysisCompletedEvent
(invariant #9 env observation — recorded live, folded on replay, never re-run). A
non-clean command fails the stage retryably with its output fed back verbatim (§2:
compiler/test output is ground truth), so the implementer fixes it and only static-clean
code ever reaches the reviewer. This makes §5's "reviewer context excludes static findings"
mechanical — the findings are resolved upstream, so the reviewer can't waste inference on
what detekt catches for free; no output-parsing, no context plumbing.
- StaticAnalysisCompletedEvent + StaticAnalysisFinding (core:events) + registration.
- StaticAnalysisRunner seam + ProcessStaticAnalysisRunner (whitespace-split argv, stderr
merged, timeout→nonzero exit) in core:kernel; wired (nullable) into OrchestratorEngines
and the server. Null runner / no workspace root → logged no-op, never blocks the run.
- StageConfig.staticAnalysis + `static_analysis` TOML field; runStaticAnalysis folded into
a runPostStageGates sequence (produces → grounding → echo → static analysis).
- Documented `static_analysis` on the role_pipeline implementer stage (commented; commands
are workspace-specific). The reviewer prompt half shipped in 3467826.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
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
|
||||
|
||||
/** The outcome of one operator-configured static-analysis command run against a stage's output. */
|
||||
@Serializable
|
||||
data class StaticAnalysisFinding(
|
||||
val command: String,
|
||||
val exitCode: Int,
|
||||
/**
|
||||
* Truncated tail of the command's combined stdout+stderr. The full output is fed back to the
|
||||
* model verbatim at runtime but is not retained in the log — the tail carries the error summary
|
||||
* and keeps the event bounded.
|
||||
*/
|
||||
val summary: String,
|
||||
) {
|
||||
val clean: Boolean get() = exitCode == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment observation (invariant #9): the static-analysis commands (compiler / detekt /
|
||||
* formatters) a stage ran against its just-produced output, each with its exit code and a truncated
|
||||
* output tail, captured at the moment the stage completed. Replay reads these recorded facts and
|
||||
* never re-runs the commands.
|
||||
*
|
||||
* Realizes role-reliability §5 "static-first ordering": deterministic tools run before the LLM
|
||||
* reviewer. A non-clean finding fails the producing stage retryably (the output is fed back so the
|
||||
* implementer fixes it), so only static-clean output ever reaches the reviewer — its context thereby
|
||||
* excludes anything static tools already catch, with no parsing or context plumbing.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("StaticAnalysisCompleted")
|
||||
data class StaticAnalysisCompletedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val findings: List<StaticAnalysisFinding>,
|
||||
) : EventPayload
|
||||
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
@@ -110,6 +111,7 @@ val eventModule = SerializersModule {
|
||||
subclass(RepoKnowledgeRetrievedEvent::class)
|
||||
subclass(BriefGroundingCheckedEvent::class)
|
||||
subclass(BriefEchoMismatchEvent::class)
|
||||
subclass(StaticAnalysisCompletedEvent::class)
|
||||
subclass(RiskAssessedEvent::class)
|
||||
subclass(ChatSessionStartedEvent::class)
|
||||
subclass(ChatTurnEvent::class)
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisFinding
|
||||
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.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class StaticAnalysisCompletedEventSerializationTest {
|
||||
|
||||
private val sample = StaticAnalysisCompletedEvent(
|
||||
sessionId = SessionId("sess-1"),
|
||||
stageId = StageId("implementer"),
|
||||
findings = listOf(
|
||||
StaticAnalysisFinding(command = "./gradlew compileKotlin", exitCode = 0, summary = "BUILD SUCCESSFUL"),
|
||||
StaticAnalysisFinding(command = "./gradlew detekt", exitCode = 1, summary = "MagicNumber at Foo.kt:42"),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `round-trips as polymorphic EventPayload`() {
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"StaticAnalysisCompleted\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes hand-written StaticAnalysisCompleted JSON`() {
|
||||
val json = """
|
||||
{"type":"StaticAnalysisCompleted","sessionId":"s","stageId":"implementer",
|
||||
"findings":[{"command":"./gradlew detekt","exitCode":2,"summary":"3 violations"}]}
|
||||
""".trimIndent()
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
assertTrue(decoded is StaticAnalysisCompletedEvent)
|
||||
val finding = (decoded as StaticAnalysisCompletedEvent).findings.single()
|
||||
assertEquals("./gradlew detekt", finding.command)
|
||||
assertEquals(2, finding.exitCode)
|
||||
assertFalse(finding.clean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean is derived from exit code zero`() {
|
||||
assertTrue(StaticAnalysisFinding("c", 0, "").clean)
|
||||
assertFalse(StaticAnalysisFinding("c", 1, "").clean)
|
||||
}
|
||||
}
|
||||
+3
@@ -28,4 +28,7 @@ data class OrchestratorEngines(
|
||||
val workspacePolicy: WorkspacePolicy? = null,
|
||||
val worldProbe: WorldProbe = FileSystemWorldProbe(),
|
||||
val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = null,
|
||||
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
|
||||
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
||||
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
||||
)
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Process-spawning [StaticAnalysisRunner]: splits each operator-configured command on whitespace
|
||||
* into argv (no shell), runs it in the workspace root with stderr merged into stdout, and returns
|
||||
* its exit code with the combined output. A spawn failure or timeout is reported as a non-zero exit
|
||||
* so the gate treats a hung or unrunnable check as a failure rather than silently passing.
|
||||
*
|
||||
* Commands come from the operator's workflow config (trusted, like prompt files), not from the LLM,
|
||||
* so they bypass the tool-approval pipeline by design. Mirrors the streaming/timeout shape of
|
||||
* ShellTool to avoid pipe-buffer deadlock on large output.
|
||||
*/
|
||||
class ProcessStaticAnalysisRunner(
|
||||
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
|
||||
) : StaticAnalysisRunner {
|
||||
|
||||
override suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
|
||||
val process = runCatching {
|
||||
ProcessBuilder(argv).directory(workingDir.toFile()).redirectErrorStream(true).start()
|
||||
}.getOrElse {
|
||||
return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}")
|
||||
}
|
||||
runCatching {
|
||||
withTimeout(timeoutMs) {
|
||||
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||
val exit = process.waitFor()
|
||||
StaticAnalysisRunResult(exit, outputDeferred.await())
|
||||
}
|
||||
}.getOrElse {
|
||||
process.destroyForcibly()
|
||||
val reason = if (it is TimeoutCancellationException) {
|
||||
"static analysis '$command' timed out after ${timeoutMs}ms"
|
||||
} else {
|
||||
it.message ?: "error running '$command'"
|
||||
}
|
||||
StaticAnalysisRunResult(EXIT_NOT_RUN, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_TIMEOUT_MS = 300_000L
|
||||
const val EXIT_NOT_RUN = -1
|
||||
val WHITESPACE = Regex("\\s+")
|
||||
}
|
||||
}
|
||||
+91
-9
@@ -30,6 +30,8 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisFinding
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
@@ -151,6 +153,11 @@ private const val HTTP_TOO_MANY_REQUESTS = 429
|
||||
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
|
||||
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 STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
||||
|
||||
@SuppressWarnings(
|
||||
"ForbiddenComment",
|
||||
"UnusedParameter",
|
||||
@@ -182,6 +189,7 @@ abstract class SessionOrchestrator(
|
||||
private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
|
||||
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||
private val worldProbe: WorldProbe = engines.worldProbe
|
||||
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
@@ -549,15 +557,7 @@ abstract class SessionOrchestrator(
|
||||
is StageExecutionResult.Success -> {
|
||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
|
||||
is StageExecutionResult.Success ->
|
||||
when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) {
|
||||
is StageExecutionResult.Success ->
|
||||
checkBriefEcho(sessionId, stageId, stageConfig)
|
||||
is StageExecutionResult.Failure -> grounded
|
||||
}
|
||||
is StageExecutionResult.Failure -> produced
|
||||
}
|
||||
runPostStageGates(sessionId, stageId, stageConfig, effectives)
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> outcome
|
||||
@@ -1468,6 +1468,88 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-validation stage gates, run in order; the first failure short-circuits and is returned.
|
||||
* Cheap deterministic checks run first (produces presence, brief grounding, brief echo), then
|
||||
* the heavier process-spawning static-analysis step last, so it only runs once a stage is
|
||||
* otherwise sound.
|
||||
*/
|
||||
private suspend fun runPostStageGates(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
||||
{ verifyProduces(sessionId, stageId, stageConfig) },
|
||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
)
|
||||
for (gate in gates) {
|
||||
val result = gate()
|
||||
if (result is StageExecutionResult.Failure) return result
|
||||
}
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
|
||||
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
|
||||
* workspace root. Every result is recorded in a [StaticAnalysisCompletedEvent] — an environment
|
||||
* observation captured at run time (invariant #9), so replay reads it back and never re-runs.
|
||||
*
|
||||
* A non-clean command fails the stage retryably with its output fed back verbatim (§2: compiler/
|
||||
* test output is ground truth, not LLM noise), so the producing stage fixes the issue and only
|
||||
* static-clean output ever reaches the downstream LLM reviewer — its context thereby excludes
|
||||
* anything static tools already catch, with no parsing or context plumbing.
|
||||
*/
|
||||
private suspend fun runStaticAnalysis(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
if (stageConfig.staticAnalysis.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
val runner = staticAnalysisRunner
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||
if (runner == null || workspaceRoot == null) {
|
||||
log.warn(
|
||||
"[Orchestrator] stage declares static_analysis but no {} is wired — skipping " +
|
||||
"session={} stage={}",
|
||||
if (runner == null) "runner" else "workspace root",
|
||||
sessionId.value, stageId.value,
|
||||
)
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
val results = stageConfig.staticAnalysis.map { command ->
|
||||
val run = runner.run(workspaceRoot, command)
|
||||
StaticAnalysisFinding(
|
||||
command = command,
|
||||
exitCode = run.exitCode,
|
||||
summary = run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP),
|
||||
) to run.output
|
||||
}
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, results.map { it.first }))
|
||||
|
||||
val failures = results.filterNot { it.first.clean }
|
||||
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] static analysis failed session={} stage={} commands={}",
|
||||
sessionId.value, stageId.value,
|
||||
failures.joinToString(", ") { it.first.command },
|
||||
)
|
||||
val detail = failures.joinToString("\n\n") { (finding, output) ->
|
||||
"$ ${finding.command} (exit ${finding.exitCode})\n${output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP)}"
|
||||
}
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not pass static analysis. Fix these before review:\n\n$detail",
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitProcessResultEvents(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Result of running one static-analysis command: its exit code and combined stdout+stderr. */
|
||||
data class StaticAnalysisRunResult(
|
||||
val exitCode: Int,
|
||||
val output: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam for running operator-configured static-analysis commands (compiler / detekt / formatters)
|
||||
* as a deterministic harness step (role-reliability §5 static-first ordering). Implemented by a
|
||||
* process-spawning adapter in infrastructure and injected, so the deterministic core never spawns
|
||||
* processes itself and the gate stays unit-testable with a fake.
|
||||
*
|
||||
* The orchestrator records each result as a [com.correx.core.events.events.StaticAnalysisCompletedEvent]
|
||||
* (invariant #9 environment observation) — replay reads the recorded facts and never re-runs the command.
|
||||
*/
|
||||
fun interface StaticAnalysisRunner {
|
||||
suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class ProcessStaticAnalysisRunnerTest {
|
||||
|
||||
private val runner = ProcessStaticAnalysisRunner()
|
||||
|
||||
private fun script(dir: Path, name: String, body: String): String {
|
||||
val file = dir.resolve(name)
|
||||
file.writeText("#!/bin/sh\n$body\n")
|
||||
file.toFile().setExecutable(true)
|
||||
return "./$name"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean command reports exit zero and captures output`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-ok")
|
||||
val cmd = script(dir, "ok.sh", "echo all-good")
|
||||
val result = runner.run(dir, cmd)
|
||||
assertEquals(0, result.exitCode)
|
||||
assertTrue(result.output.contains("all-good"), "output: ${result.output}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failing command reports nonzero exit and captures stderr`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-fail")
|
||||
// stderr is merged into the captured output (redirectErrorStream).
|
||||
val cmd = script(dir, "bad.sh", "echo boom 1>&2\nexit 2")
|
||||
val result = runner.run(dir, cmd)
|
||||
assertEquals(2, result.exitCode)
|
||||
assertTrue(result.output.contains("boom"), "output: ${result.output}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrunnable command is a failure, not a silent pass`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-missing")
|
||||
val result = runner.run(dir, "./does-not-exist-xyz")
|
||||
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank command is a failure`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-blank")
|
||||
val result = runner.run(dir, " ")
|
||||
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,10 @@ data class StageConfig(
|
||||
// the declared set is scope creep — the dominant local-implementer failure — and is
|
||||
// blocked by the plane-2 ManifestContainmentRule.
|
||||
val writeManifest: List<String> = emptyList(),
|
||||
// Operator-configured static-analysis commands (compiler/detekt/formatters) run as a
|
||||
// deterministic harness step after this stage produces its output (role-reliability §5).
|
||||
// A non-clean command fails the stage retryably with its output fed back, so only
|
||||
// static-clean output reaches the downstream reviewer. Empty = no static-first step.
|
||||
val staticAnalysis: List<String> = emptyList(),
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user