kernel: deterministic static_check stage (§B-§5) + critique-outcome producer (§B-§6)
Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network). §B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent + reviewer-context filter already existed; what was missing was a stage that runs the tool and emits the event. Added: - StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings) when no runner is wired or no command is configured — safe to carry unconfigured. - A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent, then advances on its (unconditional) exit edge. - TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata. - role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc). §B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed but nothing fed them. Added: - CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings + verdict for one review iteration, carrying modelHash for per-model calibration. - CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) / DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal), per critic (role + modelHash) and per finding id. - A hook in completeWorkflow/failWorkflow that correlates recorded findings into CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent. (LLM-side finding emission stays a separate model-gated activation.) Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner → event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration (1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
@@ -1,9 +1,38 @@
|
||||
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
|
||||
|
||||
/** A critic's verdict for one review iteration. */
|
||||
@Serializable
|
||||
enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED }
|
||||
|
||||
/**
|
||||
* The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its
|
||||
* [verdict] for that iteration. This is the **producing side** of critique calibration: the
|
||||
* reviewer-loop runtime correlates the sequence of these across iterations into
|
||||
* [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the
|
||||
* `:core:critique` projection then folds into per-(model, role) precision.
|
||||
*
|
||||
* [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the
|
||||
* correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash]
|
||||
* identifies the model that produced the findings (carried through to the outcome event so
|
||||
* calibration is per-model).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("CritiqueFindingsRecorded")
|
||||
data class CritiqueFindingsRecordedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val role: CritiqueRole,
|
||||
val modelHash: String,
|
||||
val iteration: Int,
|
||||
val verdict: CritiqueVerdict,
|
||||
val findings: List<CritiqueFinding>,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
|
||||
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.RouterNarrationEvent
|
||||
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||
@@ -142,6 +143,7 @@ val eventModule = SerializersModule {
|
||||
subclass(IdeaDiscardedEvent::class)
|
||||
subclass(IdeaPromotedEvent::class)
|
||||
subclass(CritiqueOutcomeCorrelatedEvent::class)
|
||||
subclass(CritiqueFindingsRecordedEvent::class)
|
||||
subclass(StageCheckpointPassedEvent::class)
|
||||
subclass(StageCheckpointFailedEvent::class)
|
||||
subclass(PossibleContradictionFlaggedEvent::class)
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.CritiqueFinding
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Decides how each prior critique finding turned out across a review loop — the producer half of
|
||||
* critique calibration (BACKLOG §B-§6 / pipeline-addenda A3). Pure over the recorded review
|
||||
* iterations, so it is replay-deterministic and unit-testable; the orchestrator runs it once at
|
||||
* loop resolution (see completeWorkflow / failWorkflow) and emits the results, which the
|
||||
* `:core:critique` projection folds into per-(model, role) precision.
|
||||
*
|
||||
* Rules, per critic (grouped by role + modelHash) and per finding id:
|
||||
* - **UPHELD** — the finding was raised in some iteration and is gone by the final review: the
|
||||
* implementer fixed it between rounds, so the critic was right and the finding was actioned.
|
||||
* - **DISMISSED** — the finding persists into the final review AND that review APPROVED: the
|
||||
* reviewer signed off without it being addressed, i.e. treated it as non-blocking.
|
||||
* - **INCONCLUSIVE** — the finding is still open at a non-approved terminal (loop exhausted): no
|
||||
* signal either way.
|
||||
*
|
||||
* Findings carry a stable [CritiqueFinding.id] so the same logical finding is tracked across
|
||||
* iterations.
|
||||
*/
|
||||
object CritiqueOutcomeCorrelator {
|
||||
|
||||
fun correlate(
|
||||
sessionId: SessionId,
|
||||
reviews: List<CritiqueFindingsRecordedEvent>,
|
||||
): List<CritiqueOutcomeCorrelatedEvent> =
|
||||
reviews
|
||||
.groupBy { it.role to it.modelHash }
|
||||
.flatMap { (_, group) -> correlateCritic(sessionId, group) }
|
||||
|
||||
private fun correlateCritic(
|
||||
sessionId: SessionId,
|
||||
critic: List<CritiqueFindingsRecordedEvent>,
|
||||
): List<CritiqueOutcomeCorrelatedEvent> {
|
||||
val ordered = critic.sortedBy { it.iteration }
|
||||
val finalReview = ordered.last()
|
||||
val finalIds = finalReview.findings.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
// Latest appearance per finding id (later iterations overwrite), used for severity and the
|
||||
// owning critic's role/modelHash.
|
||||
val latest = LinkedHashMap<String, Pair<CritiqueFindingsRecordedEvent, CritiqueFinding>>()
|
||||
for (review in ordered) {
|
||||
for (finding in review.findings) {
|
||||
latest[finding.id] = review to finding
|
||||
}
|
||||
}
|
||||
|
||||
return latest.map { (id, appearance) ->
|
||||
val (review, finding) = appearance
|
||||
val outcome = when {
|
||||
id !in finalIds -> CritiqueOutcome.UPHELD
|
||||
finalReview.verdict == CritiqueVerdict.APPROVED -> CritiqueOutcome.DISMISSED
|
||||
else -> CritiqueOutcome.INCONCLUSIVE
|
||||
}
|
||||
CritiqueOutcomeCorrelatedEvent(
|
||||
sessionId = sessionId,
|
||||
findingId = id,
|
||||
role = review.role,
|
||||
modelHash = review.modelHash,
|
||||
severity = finding.severity,
|
||||
outcome = outcome,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -61,11 +61,15 @@ class DefaultSessionOrchestrator(
|
||||
private val compactionService: JournalCompactionService? = null,
|
||||
artifactKindRegistry: ArtifactKindRegistry? = null,
|
||||
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||
commandRunner: CommandRunner? = null,
|
||||
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway {
|
||||
override val tokenizer: Tokenizer? = tokenizer
|
||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||
ConcurrentHashMap<SessionId, AtomicBoolean>()
|
||||
|
||||
// Deterministic static_check stages (BACKLOG §B-§5) are run here, not via the LLM subagent.
|
||||
private val staticCheckExecutor = StaticCheckStageExecutor(commandRunner)
|
||||
|
||||
override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
|
||||
executeStage = { sid, stg, graph, session, cfg ->
|
||||
executeStage(sid, stg, graph, session, cfg, effectivesFor(cfg))
|
||||
@@ -375,6 +379,20 @@ class DefaultSessionOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic stage seam (§B-§5): a `static_check` stage runs a static-analysis tool and
|
||||
// records its findings instead of invoking the LLM subagent. No retries/clarification apply.
|
||||
ctx.graph.stages[stageId]?.let { stage ->
|
||||
if (stage.metadata["stage_type"] == "static_check") {
|
||||
val event = staticCheckExecutor.execute(ctx.sessionId, stageId, stage)
|
||||
emit(ctx.sessionId, event)
|
||||
log.info(
|
||||
"[Orchestrator] static_check session={} stage={} tool={} findings={}",
|
||||
ctx.sessionId.value, stageId.value, event.tool, event.findings.size,
|
||||
)
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (isCancelled(ctx.sessionId)) {
|
||||
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
|
||||
|
||||
+18
@@ -30,6 +30,8 @@ import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.StaticFindingsRecordedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
@@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator(
|
||||
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
|
||||
sessionId.value, terminalStageId.value, stageCount,
|
||||
)
|
||||
correlateCritiqueOutcomes(sessionId)
|
||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||
@@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator(
|
||||
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
|
||||
sessionId.value, stageId.value, reason, retryExhausted,
|
||||
)
|
||||
correlateCritiqueOutcomes(sessionId)
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
||||
}
|
||||
|
||||
/**
|
||||
* At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
|
||||
* feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
|
||||
* recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
|
||||
* — it skips when outcomes already exist — so it is safe on every terminal path.
|
||||
*/
|
||||
private suspend fun correlateCritiqueOutcomes(sessionId: SessionId) {
|
||||
val events = eventStore.read(sessionId)
|
||||
if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
|
||||
val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
|
||||
if (reviews.isEmpty()) return
|
||||
CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
|
||||
}
|
||||
|
||||
internal suspend fun handleCancellation(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
|
||||
+11
-11
@@ -25,18 +25,18 @@ interface CommandRunner {
|
||||
* Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to
|
||||
* stdout — and parsing is lenient: lines that don't match any known format are ignored.
|
||||
*
|
||||
* TODO(wiring): a real `static_check` stage in role_pipeline.toml (between `implementer` and
|
||||
* `reviewer`) would invoke this runner and emit the findings as a first-class event:
|
||||
* Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this
|
||||
* runner and records a [com.correx.core.events.events.StaticFindingsRecordedEvent]; the orchestrator
|
||||
* dispatches any stage with `metadata["stage_type"] == "static_check"` to it instead of the LLM
|
||||
* subagent (see DefaultSessionOrchestrator.enterStage), and role_pipeline.toml carries the stage
|
||||
* between `implementer` and `reviewer`. Once the event is on the journal,
|
||||
* [excludeStaticFindingsFromReview] (already live-wired in SessionOrchestrator's context assembly)
|
||||
* strips the matching entries from the reviewer's context.
|
||||
*
|
||||
* val findings = StaticAnalysisRunner(commandRunner).analyze("detekt", argv)
|
||||
* eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId)
|
||||
*
|
||||
* Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in
|
||||
* SessionOrchestrator's context assembly) strips the matching entries from the reviewer's context.
|
||||
* That stage is intentionally NOT added here: workflow stages are LLM-driven and emit typed
|
||||
* artifacts, so a deterministic event-emitting stage needs a new stage-type / tool-execution seam
|
||||
* that doesn't yet exist — adding it now would be broad plumbing. The runner + event + filter are
|
||||
* complete; only the stage that calls them is deferred.
|
||||
* The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets
|
||||
* `static_argv`. TODO(activation): wiring a *production* runner means resolving the command to the
|
||||
* session's workspace (ShellTool-style ProcessBuilder + cwd + timeout) and is live-QA-gated — it is
|
||||
* deferred so the deterministic seam can land and be unit-verified without a sandbox.
|
||||
*/
|
||||
class StaticAnalysisRunner(private val runner: CommandRunner) {
|
||||
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.StaticFindingsRecordedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
|
||||
/**
|
||||
* Executes a deterministic `static_check` stage (BACKLOG §B-§5). Where an LLM stage runs a
|
||||
* subagent, this stage shells out to a configured static-analysis tool, parses its console
|
||||
* output via [StaticAnalysisRunner], and produces a [StaticFindingsRecordedEvent] — the event the
|
||||
* reviewer-context filter ([excludeStaticFindingsFromReview], already live-wired) consumes so the
|
||||
* reviewer's attention isn't spent re-finding what the build already reports.
|
||||
*
|
||||
* The stage is configured entirely through [StageConfig.metadata] (set from the workflow TOML):
|
||||
* - `stage_type = "static_check"` — marks the stage deterministic (dispatched in `enterStage`).
|
||||
* - `static_tool = "detekt"` — the label recorded on the findings (default `"static"`).
|
||||
* - `static_argv = "<command>"` — the command to run, whitespace-split (simple quotes honoured).
|
||||
*
|
||||
* It is a **no-op (empty findings) when no runner is wired or no `static_argv` is configured**, so a
|
||||
* workflow can carry the stage safely before a tool/command is set up — activation is opt-in.
|
||||
*/
|
||||
class StaticCheckStageExecutor(private val commandRunner: CommandRunner?) {
|
||||
|
||||
/** Runs the configured tool (if any) and returns the event to record; never throws on a tool miss. */
|
||||
suspend fun execute(sessionId: SessionId, stageId: StageId, stage: StageConfig): StaticFindingsRecordedEvent {
|
||||
val tool = stage.metadata["static_tool"]?.takeIf { it.isNotBlank() } ?: "static"
|
||||
val argv = stage.metadata["static_argv"]?.let { tokenizeArgv(it) } ?: emptyList()
|
||||
val findings = if (commandRunner != null && argv.isNotEmpty()) {
|
||||
StaticAnalysisRunner(commandRunner).analyze(tool, argv)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return StaticFindingsRecordedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
tool = tool,
|
||||
findings = findings,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Splits a command string into argv on whitespace, honouring simple single- or double-quoted
|
||||
* runs so a path or flag value may contain spaces. Not a full shell parser (no escapes, no
|
||||
* variable expansion) — deliberately minimal and deterministic for unit testing.
|
||||
*/
|
||||
fun tokenizeArgv(command: String): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var quote: Char? = null
|
||||
var inToken = false
|
||||
fun flush() {
|
||||
if (inToken) {
|
||||
tokens.add(current.toString())
|
||||
current.clear()
|
||||
inToken = false
|
||||
}
|
||||
}
|
||||
for (ch in command) {
|
||||
when {
|
||||
quote != null -> if (ch == quote) quote = null else current.append(ch)
|
||||
ch == '\'' || ch == '"' -> { quote = ch; inToken = true }
|
||||
ch.isWhitespace() -> flush()
|
||||
else -> { current.append(ch); inToken = true }
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.CritiqueFinding
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueRole
|
||||
import com.correx.core.events.events.CritiqueSeverity
|
||||
import com.correx.core.events.events.CritiqueVerdict
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CritiqueOutcomeCorrelatorTest {
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
|
||||
private fun finding(id: String, severity: CritiqueSeverity = CritiqueSeverity.MAJOR) = CritiqueFinding(
|
||||
id = id,
|
||||
role = CritiqueRole.CODE_REVIEWER,
|
||||
severity = severity,
|
||||
category = "correctness",
|
||||
message = "msg-$id",
|
||||
)
|
||||
|
||||
private fun review(
|
||||
iteration: Int,
|
||||
verdict: CritiqueVerdict,
|
||||
findings: List<CritiqueFinding>,
|
||||
role: CritiqueRole = CritiqueRole.CODE_REVIEWER,
|
||||
modelHash: String = "model-A",
|
||||
) = CritiqueFindingsRecordedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("reviewer"),
|
||||
role = role,
|
||||
modelHash = modelHash,
|
||||
iteration = iteration,
|
||||
verdict = verdict,
|
||||
findings = findings,
|
||||
)
|
||||
|
||||
private fun outcomeFor(events: List<CritiqueOutcomeCorrelatedEvent>, id: String) =
|
||||
events.single { it.findingId == id }.outcome
|
||||
|
||||
@Test
|
||||
fun `finding fixed between iterations is UPHELD`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.APPROVED, emptyList()),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals(CritiqueOutcome.UPHELD, out.single().outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finding persisting into an approved final review is DISMISSED`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.APPROVED, listOf(finding("f1"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finding still open at a non-approved terminal is INCONCLUSIVE`() {
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
review(2, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.INCONCLUSIVE, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single approved review with a finding dismisses it`() {
|
||||
val reviews = listOf(review(1, CritiqueVerdict.APPROVED, listOf(finding("f1"))))
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mixed fates in one loop are resolved independently per finding`() {
|
||||
val reviews = listOf(
|
||||
// f1 and f2 raised; f1 fixed next round, f2 persists; f3 raised late and persists.
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))),
|
||||
review(2, CritiqueVerdict.APPROVED, listOf(finding("f2"), finding("f3"))),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "f1"))
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f2"))
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findings carry their severity and the producing critic's identity`() {
|
||||
val reviews = listOf(
|
||||
review(
|
||||
1, CritiqueVerdict.APPROVED, listOf(finding("f1", CritiqueSeverity.BLOCKER)),
|
||||
role = CritiqueRole.PLAN_CRITIC, modelHash = "model-Z",
|
||||
),
|
||||
)
|
||||
val event = CritiqueOutcomeCorrelator.correlate(sessionId, reviews).single()
|
||||
assertEquals(CritiqueSeverity.BLOCKER, event.severity)
|
||||
assertEquals(CritiqueRole.PLAN_CRITIC, event.role)
|
||||
assertEquals("model-Z", event.modelHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each critic (role + model) is calibrated separately`() {
|
||||
val planCritic = CritiqueRole.PLAN_CRITIC
|
||||
val reviewer = CritiqueRole.CODE_REVIEWER
|
||||
val reviews = listOf(
|
||||
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("a")), role = planCritic, modelHash = "m1"),
|
||||
review(2, CritiqueVerdict.APPROVED, emptyList(), role = planCritic, modelHash = "m1"),
|
||||
review(1, CritiqueVerdict.APPROVED, listOf(finding("b")), role = reviewer, modelHash = "m2"),
|
||||
)
|
||||
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
|
||||
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "a")) // plan critic's finding fixed
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "b")) // reviewer's finding approved-through
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reviews yields no outcomes`() {
|
||||
assertTrue(CritiqueOutcomeCorrelator.correlate(sessionId, emptyList()).isEmpty())
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.StaticFindingSeverity
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StaticCheckStageExecutorTest {
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("static_check")
|
||||
|
||||
private fun fakeRunner(stdout: String = "", stderr: String = "") = object : CommandRunner {
|
||||
var lastArgv: List<String>? = null
|
||||
override suspend fun run(argv: List<String>): CommandOutput {
|
||||
lastArgv = argv
|
||||
return CommandOutput(exitCode = 0, stdout = stdout, stderr = stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokenizeArgv splits on whitespace and honours quotes`() {
|
||||
assertEquals(
|
||||
listOf("./gradlew", "detekt", "--console=plain"),
|
||||
StaticCheckStageExecutor.tokenizeArgv("./gradlew detekt --console=plain"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("tool", "--path", "src/main with space/App.kt"),
|
||||
StaticCheckStageExecutor.tokenizeArgv("""tool --path "src/main with space/App.kt""""),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("a", "b"),
|
||||
StaticCheckStageExecutor.tokenizeArgv(" a b "),
|
||||
)
|
||||
assertTrue(StaticCheckStageExecutor.tokenizeArgv("").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute runs the configured tool and records parsed findings`() = runBlocking {
|
||||
val runner = fakeRunner(stdout = "src/A.kt:10:4: warning: deprecated\nBUILD FAILED")
|
||||
val stage = StageConfig(
|
||||
metadata = mapOf(
|
||||
"stage_type" to "static_check",
|
||||
"static_tool" to "detekt",
|
||||
"static_argv" to "./gradlew detekt",
|
||||
),
|
||||
)
|
||||
val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage)
|
||||
|
||||
assertEquals("detekt", event.tool)
|
||||
assertEquals(1, event.findings.size)
|
||||
assertEquals("src/A.kt", event.findings[0].file)
|
||||
assertEquals(StaticFindingSeverity.WARNING, event.findings[0].severity)
|
||||
assertEquals(listOf("./gradlew", "detekt"), runner.lastArgv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute is a no-op when no argv is configured`() = runBlocking {
|
||||
val runner = fakeRunner(stdout = "src/A.kt:1:1: error: boom")
|
||||
val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_tool" to "detekt"))
|
||||
val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage)
|
||||
|
||||
assertEquals("detekt", event.tool)
|
||||
assertTrue(event.findings.isEmpty(), "no static_argv → tool not run, empty findings")
|
||||
assertEquals(null, runner.lastArgv, "the runner must not be invoked without a command")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute is a no-op with empty findings when no runner is wired`() = runBlocking {
|
||||
val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_argv" to "./gradlew detekt"))
|
||||
val event = StaticCheckStageExecutor(null).execute(sessionId, stageId, stage)
|
||||
|
||||
assertEquals("static", event.tool, "default tool label when unspecified")
|
||||
assertTrue(event.findings.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Role pipeline: analyst → architect → planner → implementer ⇄ reviewer
|
||||
# Role pipeline: analyst → architect → planner → implementer → static_check ⇄ reviewer
|
||||
#
|
||||
# Each stage produces a typed artifact that the next stage `needs`, so work flows forward
|
||||
# without a human relaying notes. The decision journal (pinned into every stage's context)
|
||||
@@ -69,6 +69,19 @@ allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"]
|
||||
token_budget = 32768
|
||||
max_retries = 3
|
||||
|
||||
# 4b. Deterministic static analysis (NO LLM). Runs a configured tool (compiler/ktlint/detekt)
|
||||
# over the patch and records its findings as a StaticFindingsRecordedEvent, which the
|
||||
# reviewer-context filter then strips from the reviewer's context so the LLM reviewer spends
|
||||
# its attention on semantic review rather than re-finding what the build already reports.
|
||||
# No-op until `static_argv` is set (and a CommandRunner is wired) — safe to carry unconfigured.
|
||||
# Activate e.g. with: static_argv = "./gradlew detekt --console=plain"
|
||||
[[stages]]
|
||||
id = "static_check"
|
||||
stage_type = "static_check"
|
||||
static_tool = "detekt"
|
||||
token_budget = 0
|
||||
max_retries = 0
|
||||
|
||||
# 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer
|
||||
# needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow
|
||||
# question) rather than whole files against taste.
|
||||
@@ -103,13 +116,21 @@ to = "implementer"
|
||||
condition_type = "artifact_validated"
|
||||
condition_artifact_id = "impl_plan"
|
||||
|
||||
# implementer → static_check (once the patch is validated) → reviewer. The static_check stage
|
||||
# produces no artifact, so its exit edge is unconditional (always_true).
|
||||
[[transitions]]
|
||||
id = "implementer-to-reviewer"
|
||||
id = "implementer-to-static_check"
|
||||
from = "implementer"
|
||||
to = "reviewer"
|
||||
to = "static_check"
|
||||
condition_type = "artifact_validated"
|
||||
condition_artifact_id = "patch"
|
||||
|
||||
[[transitions]]
|
||||
id = "static_check-to-reviewer"
|
||||
from = "static_check"
|
||||
to = "reviewer"
|
||||
condition_type = "always_true"
|
||||
|
||||
# --- verdict-gated loop exit / re-entry ---
|
||||
|
||||
[[transitions]]
|
||||
|
||||
+20
-7
@@ -47,6 +47,12 @@ private data class StageSection(
|
||||
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
|
||||
@param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false,
|
||||
@param:JsonProperty("ground_references") val groundReferences: Boolean = false,
|
||||
// Deterministic stage marker (§B-§5): `stage_type = "static_check"` runs a static-analysis
|
||||
// tool instead of an LLM subagent; `static_tool`/`static_argv` configure it (see
|
||||
// StaticCheckStageExecutor). Absent for ordinary LLM stages.
|
||||
@param:JsonProperty("stage_type") val stageType: String? = null,
|
||||
@param:JsonProperty("static_tool") val staticTool: String? = null,
|
||||
@param:JsonProperty("static_argv") val staticArgv: String? = null,
|
||||
)
|
||||
|
||||
// condition fields flattened into the transition row
|
||||
@@ -93,6 +99,19 @@ class TomlWorkflowLoader(
|
||||
return if (candidate != null && candidate.exists()) candidate.toString() else raw
|
||||
}
|
||||
|
||||
// Flattens a stage's TOML flags into the StageConfig.metadata string map the runtime reads
|
||||
// (prompt paths, approval/inject/ground flags, and the §B-§5 deterministic-stage markers).
|
||||
private fun stageMetadata(s: StageSection, workflowDir: Path?): Map<String, String> = buildMap {
|
||||
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
|
||||
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
|
||||
if (s.requiresApproval) put("requiresApproval", "true")
|
||||
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
|
||||
if (s.groundReferences) put("groundReferences", "true")
|
||||
s.stageType?.let { put("stage_type", it) }
|
||||
s.staticTool?.let { put("static_tool", it) }
|
||||
s.staticArgv?.let { put("static_argv", it) }
|
||||
}
|
||||
|
||||
private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph {
|
||||
val startId = StageId(start)
|
||||
val stageMap = stages.associate { s ->
|
||||
@@ -115,13 +134,7 @@ class TomlWorkflowLoader(
|
||||
maxTokens = s.tokenBudget,
|
||||
),
|
||||
maxRetries = s.maxRetries,
|
||||
metadata = buildMap {
|
||||
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
|
||||
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
|
||||
if (s.requiresApproval) put("requiresApproval", "true")
|
||||
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
|
||||
if (s.groundReferences) put("groundReferences", "true")
|
||||
},
|
||||
metadata = stageMetadata(s, workflowDir),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -53,10 +53,21 @@ class RolePipelineWorkflowTest {
|
||||
val graph = TomlWorkflowLoader(registry).load(path!!)
|
||||
|
||||
assertEquals(
|
||||
setOf("analyst", "architect", "planner", "implementer", "reviewer"),
|
||||
setOf("analyst", "architect", "planner", "implementer", "static_check", "reviewer"),
|
||||
graph.stages.keys.map { it.value }.toSet(),
|
||||
)
|
||||
assertEquals(6, graph.transitions.size)
|
||||
assertEquals(7, graph.transitions.size)
|
||||
|
||||
// The deterministic static_check stage sits between implementer and reviewer.
|
||||
assertEquals("static_check", graph.stages[StageId("static_check")]!!.metadata["stage_type"])
|
||||
assertTrue(
|
||||
graph.transitions.any { it.from == StageId("implementer") && it.to == StageId("static_check") },
|
||||
"implementer should hand off to static_check",
|
||||
)
|
||||
assertTrue(
|
||||
graph.transitions.any { it.from == StageId("static_check") && it.to == StageId("reviewer") },
|
||||
"static_check should hand off to reviewer",
|
||||
)
|
||||
|
||||
// Forward chaining via needs.
|
||||
assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan")))
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import com.correx.core.events.events.CritiqueFinding
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueRole
|
||||
import com.correx.core.events.events.CritiqueSeverity
|
||||
import com.correx.core.events.events.CritiqueVerdict
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.inference.InferenceRepository
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.journal.DecisionJournalProjector
|
||||
import com.correx.core.journal.DefaultDecisionJournalReducer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.core.risk.DefaultRiskAssessor
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
||||
import com.correx.testing.fixtures.InferenceFixtures
|
||||
import com.correx.testing.fixtures.context.ContextFixtures
|
||||
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
||||
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
||||
import com.correx.testing.kernel.MockSessionEventReplayer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* End-to-end proof that the reviewer-loop runtime correlates recorded critique findings into
|
||||
* outcome events at loop resolution (BACKLOG §B-§6), and that those events feed the
|
||||
* `:core:critique` calibration projection. The LLM-side emission of findings is mocked here by
|
||||
* seeding [CritiqueFindingsRecordedEvent]s directly (that half is a separate model-gated step).
|
||||
*/
|
||||
class CritiqueCalibrationWiringTest {
|
||||
|
||||
private val eventStore = InMemoryEventStore()
|
||||
private val sessionReplayer = MockSessionEventReplayer()
|
||||
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
|
||||
private val orchestrationRepository = OrchestrationRepository(
|
||||
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||
)
|
||||
private val inferenceRepository = InferenceRepository(
|
||||
object : EventReplayer<InferenceState> {
|
||||
override fun rebuild(sessionId: SessionId) = InferenceState()
|
||||
},
|
||||
)
|
||||
private val approvalRepository = DefaultApprovalRepository(
|
||||
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
||||
)
|
||||
private val decisionJournalRepository = DefaultDecisionJournalRepository(
|
||||
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
||||
)
|
||||
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
|
||||
|
||||
private val repositories = OrchestratorRepositories(
|
||||
eventStore = eventStore,
|
||||
inferenceRepository = inferenceRepository,
|
||||
orchestrationRepository = orchestrationRepository,
|
||||
sessionRepository = sessionRepository,
|
||||
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||
approvalRepository = approvalRepository,
|
||||
)
|
||||
|
||||
private val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = OrchestratorEngines(
|
||||
transitionResolver = TransitionFixtures.simpleResolver(),
|
||||
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
||||
inferenceRouter = InferenceFixtures.fixedRouter(),
|
||||
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
||||
approvalEngine = DefaultApprovalEngine(),
|
||||
riskAssessor = DefaultRiskAssessor(),
|
||||
),
|
||||
retryCoordinator = retryCoordinator,
|
||||
artifactStore = NoopArtifactStore(),
|
||||
decisionJournalRepository = decisionJournalRepository,
|
||||
)
|
||||
|
||||
private val defaultConfig = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||
|
||||
private fun finding(id: String) = CritiqueFinding(
|
||||
id = id,
|
||||
role = CritiqueRole.CODE_REVIEWER,
|
||||
severity = CritiqueSeverity.MAJOR,
|
||||
category = "correctness",
|
||||
message = "msg-$id",
|
||||
)
|
||||
|
||||
private suspend fun seedReview(sessionId: SessionId, iteration: Int, verdict: CritiqueVerdict, findings: List<CritiqueFinding>) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
EventMetadata(EventId("rev-$iteration"), sessionId, Clock.System.now(), 1, null, null),
|
||||
CritiqueFindingsRecordedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("reviewer"),
|
||||
role = CritiqueRole.CODE_REVIEWER,
|
||||
modelHash = "model-A",
|
||||
iteration = iteration,
|
||||
verdict = verdict,
|
||||
findings = findings,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recorded findings are correlated at completion and feed the calibration projection`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("calib-1")
|
||||
|
||||
// Two review rounds: f1 fixed between them (→ UPHELD); f2 persists to an approved final (→ DISMISSED).
|
||||
seedReview(sessionId, 1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2")))
|
||||
seedReview(sessionId, 2, CritiqueVerdict.APPROVED, listOf(finding("f2")))
|
||||
|
||||
val stageA = StageId("A")
|
||||
val done = StageId("done")
|
||||
val graph = WorkflowGraph(
|
||||
id = "calib-wiring",
|
||||
stages = mapOf(stageA to StageConfig(), done to StageConfig()),
|
||||
transitions = setOf(TransitionEdge(id = TransitionId("t1"), from = stageA, to = done, condition = { true })),
|
||||
start = stageA,
|
||||
)
|
||||
|
||||
orchestrator.run(sessionId, graph, defaultConfig)
|
||||
|
||||
// The orchestrator emits one outcome per recorded finding at completion — the events the
|
||||
// :core:critique calibration projection consumes (its folding is covered by that module's
|
||||
// DefaultCriticCalibrationReducerTest).
|
||||
val outcomes = eventStore.read(sessionId).mapNotNull { it.payload as? CritiqueOutcomeCorrelatedEvent }
|
||||
assertEquals(2, outcomes.size, "one outcome per recorded finding")
|
||||
assertEquals(CritiqueOutcome.UPHELD, outcomes.single { it.findingId == "f1" }.outcome)
|
||||
assertEquals(CritiqueOutcome.DISMISSED, outcomes.single { it.findingId == "f2" }.outcome)
|
||||
assertTrue(outcomes.all { it.modelHash == "model-A" && it.role == CritiqueRole.CODE_REVIEWER })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import com.correx.core.events.events.StaticFindingsRecordedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.inference.InferenceRepository
|
||||
import com.correx.core.inference.InferenceState
|
||||
import com.correx.core.journal.DecisionJournalProjector
|
||||
import com.correx.core.journal.DefaultDecisionJournalReducer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.CommandOutput
|
||||
import com.correx.core.kernel.orchestration.CommandRunner
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.core.risk.DefaultRiskAssessor
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
||||
import com.correx.testing.fixtures.InferenceFixtures
|
||||
import com.correx.testing.fixtures.context.ContextFixtures
|
||||
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
||||
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
||||
import com.correx.testing.kernel.MockSessionEventReplayer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* End-to-end proof of the deterministic static_check stage seam (BACKLOG §B-§5): a stage marked
|
||||
* `stage_type = "static_check"` runs the configured tool via an injected [CommandRunner] (no LLM
|
||||
* subagent), records a [StaticFindingsRecordedEvent] with the parsed findings, and the workflow
|
||||
* advances on the unconditional exit edge.
|
||||
*/
|
||||
class StaticCheckStageTest {
|
||||
|
||||
private val eventStore = InMemoryEventStore()
|
||||
private val sessionReplayer = MockSessionEventReplayer()
|
||||
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
|
||||
private val orchestrationRepository = OrchestrationRepository(
|
||||
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||
)
|
||||
private val inferenceRepository = InferenceRepository(
|
||||
object : EventReplayer<InferenceState> {
|
||||
override fun rebuild(sessionId: SessionId) = InferenceState()
|
||||
},
|
||||
)
|
||||
private val approvalRepository = DefaultApprovalRepository(
|
||||
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
||||
)
|
||||
private val decisionJournalRepository = DefaultDecisionJournalRepository(
|
||||
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
||||
)
|
||||
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
|
||||
|
||||
private val repositories = OrchestratorRepositories(
|
||||
eventStore = eventStore,
|
||||
inferenceRepository = inferenceRepository,
|
||||
orchestrationRepository = orchestrationRepository,
|
||||
sessionRepository = sessionRepository,
|
||||
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||
approvalRepository = approvalRepository,
|
||||
)
|
||||
|
||||
// Canned tool output: one stderr error + one stdout detekt finding (+ noise that must be ignored).
|
||||
private val fakeRunner = object : CommandRunner {
|
||||
var lastArgv: List<String>? = null
|
||||
override suspend fun run(argv: List<String>): CommandOutput {
|
||||
lastArgv = argv
|
||||
return CommandOutput(
|
||||
exitCode = 1,
|
||||
stdout = "src/Foo.kt:42:9: Magic number found [MagicNumber]\nBUILD FAILED",
|
||||
stderr = "src/Bar.kt:5:9: error: type mismatch",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = OrchestratorEngines(
|
||||
transitionResolver = TransitionFixtures.simpleResolver(),
|
||||
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
||||
inferenceRouter = InferenceFixtures.fixedRouter(),
|
||||
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
||||
approvalEngine = DefaultApprovalEngine(),
|
||||
riskAssessor = DefaultRiskAssessor(),
|
||||
),
|
||||
retryCoordinator = retryCoordinator,
|
||||
artifactStore = NoopArtifactStore(),
|
||||
decisionJournalRepository = decisionJournalRepository,
|
||||
commandRunner = fakeRunner,
|
||||
)
|
||||
|
||||
private val defaultConfig = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `static_check stage runs the tool deterministically and records findings`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("static-check-1")
|
||||
val staticCheck = StageId("static_check")
|
||||
val done = StageId("done")
|
||||
|
||||
val graph = WorkflowGraph(
|
||||
id = "static-check-seam",
|
||||
stages = mapOf(
|
||||
staticCheck to StageConfig(
|
||||
metadata = mapOf(
|
||||
"stage_type" to "static_check",
|
||||
"static_tool" to "detekt",
|
||||
"static_argv" to "./gradlew detekt --console=plain",
|
||||
),
|
||||
),
|
||||
done to StageConfig(),
|
||||
),
|
||||
transitions = setOf(
|
||||
TransitionEdge(id = TransitionId("t1"), from = staticCheck, to = done, condition = { true }),
|
||||
),
|
||||
start = staticCheck,
|
||||
)
|
||||
|
||||
orchestrator.run(sessionId, graph, defaultConfig)
|
||||
|
||||
val events = eventStore.read(sessionId)
|
||||
val recorded = events.mapNotNull { it.payload as? StaticFindingsRecordedEvent }
|
||||
assertEquals(1, recorded.size, "the static_check stage must record exactly one findings event")
|
||||
val event = recorded.single()
|
||||
assertEquals("detekt", event.tool)
|
||||
assertEquals(2, event.findings.size, "both the stdout detekt finding and the stderr error parse")
|
||||
assertEquals("MagicNumber", event.findings[0].ruleId)
|
||||
assertEquals("src/Bar.kt", event.findings[1].file)
|
||||
assertEquals(listOf("./gradlew", "detekt", "--console=plain"), fakeRunner.lastArgv)
|
||||
|
||||
assertNotNull(
|
||||
events.find { it.payload is WorkflowCompletedEvent },
|
||||
"workflow advances past static_check on the unconditional exit edge and completes",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user