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:
@@ -34,6 +34,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig
|
|||||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
|
import com.correx.core.kernel.orchestration.ProcessStaticAnalysisRunner
|
||||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
import com.correx.core.kernel.orchestration.WorkspaceContext
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceTools
|
import com.correx.core.kernel.orchestration.WorkspaceTools
|
||||||
@@ -277,6 +278,7 @@ fun main() {
|
|||||||
toolCallAssessor = toolCallAssessor,
|
toolCallAssessor = toolCallAssessor,
|
||||||
workspacePolicy = workspacePolicy,
|
workspacePolicy = workspacePolicy,
|
||||||
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
||||||
|
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
||||||
)
|
)
|
||||||
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
||||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||||
|
|||||||
@@ -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.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
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.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||||
@@ -110,6 +111,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(RepoKnowledgeRetrievedEvent::class)
|
subclass(RepoKnowledgeRetrievedEvent::class)
|
||||||
subclass(BriefGroundingCheckedEvent::class)
|
subclass(BriefGroundingCheckedEvent::class)
|
||||||
subclass(BriefEchoMismatchEvent::class)
|
subclass(BriefEchoMismatchEvent::class)
|
||||||
|
subclass(StaticAnalysisCompletedEvent::class)
|
||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
subclass(ChatSessionStartedEvent::class)
|
subclass(ChatSessionStartedEvent::class)
|
||||||
subclass(ChatTurnEvent::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 workspacePolicy: WorkspacePolicy? = null,
|
||||||
val worldProbe: WorldProbe = FileSystemWorldProbe(),
|
val worldProbe: WorldProbe = FileSystemWorldProbe(),
|
||||||
val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = null,
|
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.ArtifactCreatedEvent
|
||||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
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.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.ClarificationAnswer
|
import com.correx.core.events.events.ClarificationAnswer
|
||||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
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.
|
// Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds.
|
||||||
private const val MAX_CLARIFICATION_ROUNDS = 3
|
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(
|
@SuppressWarnings(
|
||||||
"ForbiddenComment",
|
"ForbiddenComment",
|
||||||
"UnusedParameter",
|
"UnusedParameter",
|
||||||
@@ -182,6 +189,7 @@ abstract class SessionOrchestrator(
|
|||||||
private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
|
private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
|
||||||
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||||
private val worldProbe: WorldProbe = engines.worldProbe
|
private val worldProbe: WorldProbe = engines.worldProbe
|
||||||
|
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||||
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
||||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||||
@@ -549,15 +557,7 @@ abstract class SessionOrchestrator(
|
|||||||
is StageExecutionResult.Success -> {
|
is StageExecutionResult.Success -> {
|
||||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||||
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||||
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
|
runPostStageGates(sessionId, stageId, stageConfig, effectives)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is StageExecutionResult.Failure -> outcome
|
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(
|
private suspend fun emitProcessResultEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
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
|
// the declared set is scope creep — the dominant local-implementer failure — and is
|
||||||
// blocked by the plane-2 ManifestContainmentRule.
|
// blocked by the plane-2 ManifestContainmentRule.
|
||||||
val writeManifest: List<String> = emptyList(),
|
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(),
|
val metadata: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,12 +75,20 @@ max_retries = 2
|
|||||||
# stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open
|
# stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open
|
||||||
# here because the targets are task-specific; a task-scoped workflow would set e.g.
|
# here because the targets are task-specific; a task-scoped workflow would set e.g.
|
||||||
# writes = ["core/sessions/**", "testing/sessions/**"]
|
# writes = ["core/sessions/**", "testing/sessions/**"]
|
||||||
|
# Optional: `static_analysis` runs deterministic tools (compiler/detekt/formatters)
|
||||||
|
# against the patch BEFORE the reviewer (role-reliability §5). A non-clean command
|
||||||
|
# fails this stage retryably with its output fed back verbatim, so only static-clean
|
||||||
|
# code reaches the reviewer — the reviewer never spends inference on what tools catch
|
||||||
|
# for free. Commands are workspace-specific, so left commented; a Kotlin/Gradle repo
|
||||||
|
# would set e.g.
|
||||||
|
# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"]
|
||||||
[[stages]]
|
[[stages]]
|
||||||
id = "implementer"
|
id = "implementer"
|
||||||
prompt = "prompts/implementer.md"
|
prompt = "prompts/implementer.md"
|
||||||
needs = ["impl_plan"]
|
needs = ["impl_plan"]
|
||||||
produces = [{ name = "patch", kind = "file_written" }]
|
produces = [{ name = "patch", kind = "file_written" }]
|
||||||
allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"]
|
allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"]
|
||||||
|
# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"]
|
||||||
token_budget = 32768
|
token_budget = 32768
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
|
||||||
|
|||||||
+2
@@ -42,6 +42,7 @@ private data class StageSection(
|
|||||||
val needs: List<String> = emptyList(),
|
val needs: List<String> = emptyList(),
|
||||||
@param:JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
|
@param:JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
|
||||||
val writes: List<String> = emptyList(),
|
val writes: List<String> = emptyList(),
|
||||||
|
@param:JsonProperty("static_analysis") val staticAnalysis: List<String> = emptyList(),
|
||||||
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
|
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
|
||||||
@param:JsonProperty("max_retries") val maxRetries: Int = 3,
|
@param:JsonProperty("max_retries") val maxRetries: Int = 3,
|
||||||
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
|
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
|
||||||
@@ -106,6 +107,7 @@ class TomlWorkflowLoader(
|
|||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||||
allowedTools = s.allowedTools.toSet(),
|
allowedTools = s.allowedTools.toSet(),
|
||||||
writeManifest = s.writes,
|
writeManifest = s.writes,
|
||||||
|
staticAnalysis = s.staticAnalysis,
|
||||||
tokenBudget = s.tokenBudget,
|
tokenBudget = s.tokenBudget,
|
||||||
// Propagate the declared token budget to the inference completion cap.
|
// Propagate the declared token budget to the inference completion cap.
|
||||||
// Without this the StageConfig default (maxTokens=2048) is used, truncating
|
// Without this the StageConfig default (maxTokens=2048) is used, truncating
|
||||||
|
|||||||
+19
@@ -156,6 +156,25 @@ class TomlWorkflowLoaderTest {
|
|||||||
assertEquals(emptyList(), collectStage.writeManifest)
|
assertEquals(emptyList(), collectStage.writeManifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `static_analysis maps to the stage static-analysis commands`() {
|
||||||
|
val toml = validToml.replace(
|
||||||
|
"allowed_tools = [\"ShellTool\"]",
|
||||||
|
"allowed_tools = [\"ShellTool\"]\nstatic_analysis = [\"./gradlew compileKotlin\", \"./gradlew detekt\"]",
|
||||||
|
)
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) }
|
||||||
|
val graph = loader.load(path)
|
||||||
|
val collectStage = graph.stages[graph.start]!!
|
||||||
|
assertEquals(listOf("./gradlew compileKotlin", "./gradlew detekt"), collectStage.staticAnalysis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `static_analysis absent means no commands`() {
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
|
||||||
|
val graph = loader.load(path)
|
||||||
|
assertEquals(emptyList(), graph.stages[graph.start]!!.staticAnalysis)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ground_references true maps to metadata key`() {
|
fun `ground_references true maps to metadata key`() {
|
||||||
val toml = validToml.replace(
|
val toml = validToml.replace(
|
||||||
|
|||||||
Reference in New Issue
Block a user