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
|
package com.correx.core.events.events
|
||||||
|
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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.
|
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
|
||||||
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
|
* 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.ClarificationAnsweredEvent
|
||||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||||
import com.correx.core.events.events.ChatTurnEvent
|
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.CritiqueOutcomeCorrelatedEvent
|
||||||
import com.correx.core.events.events.RouterNarrationEvent
|
import com.correx.core.events.events.RouterNarrationEvent
|
||||||
import com.correx.core.events.events.OperatorProfileBoundEvent
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
@@ -142,6 +143,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(IdeaDiscardedEvent::class)
|
subclass(IdeaDiscardedEvent::class)
|
||||||
subclass(IdeaPromotedEvent::class)
|
subclass(IdeaPromotedEvent::class)
|
||||||
subclass(CritiqueOutcomeCorrelatedEvent::class)
|
subclass(CritiqueOutcomeCorrelatedEvent::class)
|
||||||
|
subclass(CritiqueFindingsRecordedEvent::class)
|
||||||
subclass(StageCheckpointPassedEvent::class)
|
subclass(StageCheckpointPassedEvent::class)
|
||||||
subclass(StageCheckpointFailedEvent::class)
|
subclass(StageCheckpointFailedEvent::class)
|
||||||
subclass(PossibleContradictionFlaggedEvent::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,
|
private val compactionService: JournalCompactionService? = null,
|
||||||
artifactKindRegistry: ArtifactKindRegistry? = null,
|
artifactKindRegistry: ArtifactKindRegistry? = null,
|
||||||
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
|
||||||
|
commandRunner: CommandRunner? = null,
|
||||||
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway {
|
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway {
|
||||||
override val tokenizer: Tokenizer? = tokenizer
|
override val tokenizer: Tokenizer? = tokenizer
|
||||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||||
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(
|
override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
|
||||||
executeStage = { sid, stg, graph, session, cfg ->
|
executeStage = { sid, stg, graph, session, cfg ->
|
||||||
executeStage(sid, stg, graph, session, cfg, effectivesFor(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) {
|
while (true) {
|
||||||
if (isCancelled(ctx.sessionId)) {
|
if (isCancelled(ctx.sessionId)) {
|
||||||
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
|
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.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
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.CritiqueFindingsRecordedEvent
|
||||||
|
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||||
import com.correx.core.events.events.StaticFindingsRecordedEvent
|
import com.correx.core.events.events.StaticFindingsRecordedEvent
|
||||||
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
|
||||||
@@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator(
|
|||||||
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
|
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
|
||||||
sessionId.value, terminalStageId.value, stageCount,
|
sessionId.value, terminalStageId.value, stageCount,
|
||||||
)
|
)
|
||||||
|
correlateCritiqueOutcomes(sessionId)
|
||||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
||||||
cancellations.remove(sessionId)
|
cancellations.remove(sessionId)
|
||||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||||
@@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator(
|
|||||||
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
|
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
|
||||||
sessionId.value, stageId.value, reason, retryExhausted,
|
sessionId.value, stageId.value, reason, retryExhausted,
|
||||||
)
|
)
|
||||||
|
correlateCritiqueOutcomes(sessionId)
|
||||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||||
cancellations.remove(sessionId)
|
cancellations.remove(sessionId)
|
||||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
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(
|
internal suspend fun handleCancellation(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
|
|||||||
+11
-11
@@ -25,18 +25,18 @@ interface CommandRunner {
|
|||||||
* Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to
|
* 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.
|
* 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
|
* Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this
|
||||||
* `reviewer`) would invoke this runner and emit the findings as a first-class event:
|
* 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)
|
* The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets
|
||||||
* eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId)
|
* `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
|
||||||
* Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in
|
* deferred so the deterministic seam can land and be unit-verified without a sandbox.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class StaticAnalysisRunner(private val runner: CommandRunner) {
|
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
|
# 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)
|
# 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
|
token_budget = 32768
|
||||||
max_retries = 3
|
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
|
# 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
|
# needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow
|
||||||
# question) rather than whole files against taste.
|
# question) rather than whole files against taste.
|
||||||
@@ -103,13 +116,21 @@ to = "implementer"
|
|||||||
condition_type = "artifact_validated"
|
condition_type = "artifact_validated"
|
||||||
condition_artifact_id = "impl_plan"
|
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]]
|
[[transitions]]
|
||||||
id = "implementer-to-reviewer"
|
id = "implementer-to-static_check"
|
||||||
from = "implementer"
|
from = "implementer"
|
||||||
to = "reviewer"
|
to = "static_check"
|
||||||
condition_type = "artifact_validated"
|
condition_type = "artifact_validated"
|
||||||
condition_artifact_id = "patch"
|
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 ---
|
# --- verdict-gated loop exit / re-entry ---
|
||||||
|
|
||||||
[[transitions]]
|
[[transitions]]
|
||||||
|
|||||||
+20
-7
@@ -47,6 +47,12 @@ private data class StageSection(
|
|||||||
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
|
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
|
||||||
@param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false,
|
@param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false,
|
||||||
@param:JsonProperty("ground_references") val groundReferences: 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
|
// condition fields flattened into the transition row
|
||||||
@@ -93,6 +99,19 @@ class TomlWorkflowLoader(
|
|||||||
return if (candidate != null && candidate.exists()) candidate.toString() else raw
|
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 {
|
private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph {
|
||||||
val startId = StageId(start)
|
val startId = StageId(start)
|
||||||
val stageMap = stages.associate { s ->
|
val stageMap = stages.associate { s ->
|
||||||
@@ -115,13 +134,7 @@ class TomlWorkflowLoader(
|
|||||||
maxTokens = s.tokenBudget,
|
maxTokens = s.tokenBudget,
|
||||||
),
|
),
|
||||||
maxRetries = s.maxRetries,
|
maxRetries = s.maxRetries,
|
||||||
metadata = buildMap {
|
metadata = stageMetadata(s, workflowDir),
|
||||||
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")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -53,10 +53,21 @@ class RolePipelineWorkflowTest {
|
|||||||
val graph = TomlWorkflowLoader(registry).load(path!!)
|
val graph = TomlWorkflowLoader(registry).load(path!!)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf("analyst", "architect", "planner", "implementer", "reviewer"),
|
setOf("analyst", "architect", "planner", "implementer", "static_check", "reviewer"),
|
||||||
graph.stages.keys.map { it.value }.toSet(),
|
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.
|
// Forward chaining via needs.
|
||||||
assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan")))
|
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