wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment
This branch's uncommitted WIP, committed together (entangled at file level). Distinct pieces of work: Freestyle QA fixes (this session): - FileEditTool: pre-validate replace anchor in validateRequest — reject a missing/ambiguous target BEFORE the approval gate, mirroring read/write's file-not-found / read-before-write pre-checks. Shared not-found/ambiguous messages between validate and execute so they can't drift. - PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded, repoMapPaths is "unknown" not "empty workspace" — skip scope grounding (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely rejected. Build-manifest check still runs. - FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted). - ServerModule: resolve project-memory workspace root from the session's bound workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing the workspace-binding divergence (correx vs empty scratch dir). Retire tracked in Vikunja #266. - LaunchRegistrationRaceTest: join registered jobs before asserting launchCount — computeIfAbsent returns the Job immediately but the fire-and-forget launch body lagged awaitAll (the 49-vs-50 flake). ACR concept-compiler experiment (pre-existing WIP on this branch): - ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts), LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace, config surface, workflow prompts/schemas, orchestrator advance-don't-rerun. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
|
||||
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
|
||||
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
|
||||
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
|
||||
- `[orchestration].review_loop_max_cycles` bounds review→rework cycles before recovery escalation; default `3`.
|
||||
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -217,6 +217,7 @@ object ConfigLoader {
|
||||
private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3
|
||||
private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
||||
private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20
|
||||
private const val DEFAULT_REVIEW_LOOP_MAX_CYCLES = 3
|
||||
private const val DEFAULT_MAX_REFINEMENT = 3
|
||||
private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2
|
||||
private const val DEFAULT_INTENT_ROUTE_BUDGET = 2
|
||||
@@ -716,6 +717,8 @@ object ConfigLoader {
|
||||
reviewBlockMinConfidence =
|
||||
asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE),
|
||||
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP),
|
||||
reviewLoopMaxCycles =
|
||||
asInt(orchestrationSection["review_loop_max_cycles"], DEFAULT_REVIEW_LOOP_MAX_CYCLES),
|
||||
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT),
|
||||
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET),
|
||||
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET),
|
||||
|
||||
@@ -130,6 +130,8 @@ data class OrchestrationKnobs(
|
||||
val maxClarificationRounds: Int = 3,
|
||||
val reviewBlockMinConfidence: Double = 0.7,
|
||||
val reviewBlockRetryCap: Int = 20,
|
||||
/** Review→rework cycles before deterministic escalation to the recovery stage. */
|
||||
val reviewLoopMaxCycles: Int = 3,
|
||||
val defaultMaxRefinement: Int = 3,
|
||||
val recoveryRouteBudget: Int = 2,
|
||||
val intentRouteBudget: Int = 2,
|
||||
|
||||
@@ -98,6 +98,7 @@ object CorrexConfigWriter {
|
||||
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
|
||||
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
|
||||
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
|
||||
b.kv("review_loop_max_cycles", cfg.orchestration.reviewLoopMaxCycles)
|
||||
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
|
||||
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
|
||||
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
|
||||
|
||||
@@ -20,6 +20,7 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
|
||||
- `JsonEventSerializer` / `EventSerializer` — serialize/deserialize `StoredEvent` to JSON.
|
||||
- `EventDispatcher` — broadcasts events to in-process listeners.
|
||||
- Domain event files: `ApprovalEvents`, `ArtifactEvents`, `ContextEvents`, `InferenceEvents`, `OrchestrationEvents`, `RouterEvents`, `SessionEvents`, `TaskEvents`, `ToolEvents`, `IntentEvents`, `RiskAssessedEvent`, `JournalCompactedEvent`, and many more — all payload definitions live here.
|
||||
- `LspDiagnosticsCompletedEvent` records pulled language-server diagnostics or a graceful skip reason; replay consumes this observation and never contacts the server.
|
||||
- Shared vocabulary: `IdentityTypes` (SessionId, TaskId, etc.), `Tier`, `TokenUsage`, `ToolReceipt`, `ToolRequest`, `RiskLevel`, `RetryPolicy`, `GrantScope`, `GrantLedger`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
|
||||
@Serializable
|
||||
data class LspDiagnostic(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val character: Int,
|
||||
val severity: String,
|
||||
val code: String? = null,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
/** Recorded LSP 3.17 pull-diagnostic observation; replay never re-queries a language server. */
|
||||
@Serializable
|
||||
@SerialName("LspDiagnosticsCompleted")
|
||||
data class LspDiagnosticsCompletedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val server: String?,
|
||||
val diagnostics: List<LspDiagnostic>,
|
||||
val skippedReason: String? = null,
|
||||
) : EventPayload
|
||||
@@ -16,6 +16,7 @@ import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
@@ -173,6 +174,7 @@ val eventModule = SerializersModule {
|
||||
subclass(BriefGroundingCheckedEvent::class)
|
||||
subclass(BriefEchoMismatchEvent::class)
|
||||
subclass(StaticAnalysisCompletedEvent::class)
|
||||
subclass(LspDiagnosticsCompletedEvent::class)
|
||||
subclass(ContractGateEvaluatedEvent::class)
|
||||
subclass(PlanLintCompletedEvent::class)
|
||||
subclass(RiskAssessedEvent::class)
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.LspDiagnostic
|
||||
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class LspDiagnosticsCompletedEventSerializationTest {
|
||||
@Test
|
||||
fun `round-trips as polymorphic EventPayload`() {
|
||||
val event = LspDiagnosticsCompletedEvent(
|
||||
SessionId("s"), StageId("impl"), "tsserver",
|
||||
listOf(LspDiagnostic("src/App.tsx", 1, 2, "error", "2322", "not assignable")),
|
||||
)
|
||||
val encoded = eventJson.encodeToString(com.correx.core.events.events.EventPayload.serializer(), event)
|
||||
val decoded = eventJson.decodeFromString(com.correx.core.events.events.EventPayload.serializer(), encoded)
|
||||
assertIs<LspDiagnosticsCompletedEvent>(decoded)
|
||||
assertEquals("src/App.tsx", decoded.diagnostics.single().path)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
|
||||
- `ReplayOrchestrator` / `ReplayInferenceProvider` / `ReplayStrategy` — deterministic replay of a session from its event log. `ReplayInferenceProvider` returns recorded responses — no live LLM (Hard Invariant #8).
|
||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
||||
- `LspDiagnosticsRunner` — injected pull-diagnostics seam; diagnostics are filtered to stage-written files, recorded, and enforced before build/review.
|
||||
- Review→rework loops use the configured three-cycle default, then route accumulated notes to recovery once and fail if the fixed DoD still cannot be approved.
|
||||
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
|
||||
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
|
||||
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
|
||||
|
||||
+12
-4
@@ -15,6 +15,7 @@ import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
@@ -226,8 +227,8 @@ class DefaultSessionOrchestrator(
|
||||
|
||||
/**
|
||||
* Delivers the operator's answers to a pending clarification raised by a stage. The waiting
|
||||
* stage (parked in [requestClarificationIfNeeded]) completes and re-runs with the answers in
|
||||
* context. If the server restarted while the clarification was pending there is no live
|
||||
* stage (parked in [requestClarificationIfNeeded]) unparks and the run advances to the next
|
||||
* stage with the answers in context. If the server restarted while the clarification was pending there is no live
|
||||
* coroutine to complete, so the answers are recorded directly and the session resumed.
|
||||
*/
|
||||
suspend fun submitClarification(
|
||||
@@ -280,10 +281,17 @@ class DefaultSessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
|
||||
// A back-edge re-enters any stage already visited this run (e.g. reviewer→implementer). The start
|
||||
// stage is visited via WorkflowStarted rather than TransitionExecuted, so it must participate too.
|
||||
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
|
||||
internal fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
|
||||
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
|
||||
events.any {
|
||||
when (val payload = it.payload) {
|
||||
is WorkflowStartedEvent -> payload.startStageId == target
|
||||
is TransitionExecutedEvent -> payload.to == target
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StepResult {
|
||||
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
|
||||
|
||||
+45
-6
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.RetrySalvageDecidedEvent
|
||||
import com.correx.core.events.events.SalvageDecision
|
||||
@@ -135,6 +136,7 @@ internal tailrec suspend fun DefaultSessionOrchestrator.step(ctx: EnrichedExecut
|
||||
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
|
||||
*/
|
||||
|
||||
@Suppress("LongMethod", "ReturnCount", "NestedBlockDepth")
|
||||
internal suspend fun DefaultSessionOrchestrator.executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
@@ -154,10 +156,43 @@ internal suspend fun DefaultSessionOrchestrator.executeMove(
|
||||
// terminal failure instead of looping forever.
|
||||
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
|
||||
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
|
||||
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
|
||||
val reviewerRole = ctx.graph.stages[ctx.currentStageId]?.metadata?.get("role")?.lowercase()
|
||||
val reviewLoop = reviewerRole in setOf("review", "reviewer")
|
||||
val maxIterations = if (reviewLoop) {
|
||||
tuning.reviewLoopMaxCycles
|
||||
} else {
|
||||
ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
|
||||
}
|
||||
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
||||
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
||||
if (iteration > maxIterations) {
|
||||
if (reviewLoop) {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val alreadyRecovered = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
.any { it.gate == REVIEW_LOOP_GATE && it.stageId == ctx.currentStageId }
|
||||
val notes = ctx.graph.stages[ctx.currentStageId]?.produces
|
||||
?.mapNotNull { artifactContentCache["${ctx.sessionId.value}:${it.name.value}"] }
|
||||
?.joinToString("\n\n")
|
||||
.orEmpty()
|
||||
val reason = "review loop exhausted after exactly $maxIterations cycles. " +
|
||||
"The fixed DoD was not approved. Accumulated review notes:\n" +
|
||||
notes.ifBlank { "(review stage emitted no retained notes)" }
|
||||
if (!alreadyRecovered) {
|
||||
return routeToRecovery(
|
||||
ctx,
|
||||
ctx.currentStageId,
|
||||
REVIEW_LOOP_GATE,
|
||||
"review_convergence",
|
||||
reason,
|
||||
orchestrationRepository.getState(ctx.sessionId),
|
||||
) ?: StepResult.Terminal(
|
||||
failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
|
||||
)
|
||||
}
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
|
||||
)
|
||||
}
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
@@ -179,6 +214,8 @@ internal suspend fun DefaultSessionOrchestrator.executeMove(
|
||||
}
|
||||
}
|
||||
|
||||
private const val REVIEW_LOOP_GATE = "review_loop"
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
ctx: EnrichedExecutionContext,
|
||||
@@ -224,11 +261,13 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
).outcome
|
||||
when (result) {
|
||||
is StageExecutionResult.Success -> {
|
||||
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
|
||||
// The stage raised open questions and the operator answered them; loop to
|
||||
// re-run the stage with the answers injected (no failure-retry budget spent).
|
||||
continue
|
||||
}
|
||||
// The stage may emit open questions in its artifact (discovery does). Park, let the
|
||||
// operator answer, and record the answers — then ADVANCE, do not re-run the stage.
|
||||
// A re-run restarts inference with no prior CoT, so the stage re-explores instead of
|
||||
// converging (2026-07-18). The answers are recorded as ClarificationAnsweredEvents
|
||||
// and injected into every later stage's L0 context (buildClarificationAnswerEntries),
|
||||
// so the next stage (e.g. analyst) sees them without discovery running again.
|
||||
requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)
|
||||
compactionService?.let { svc ->
|
||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||
val journalText = DecisionJournalRenderer().render(journalState)
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.LspDiagnostic
|
||||
import java.nio.file.Path
|
||||
|
||||
data class LspDiagnosticsRequest(
|
||||
val workspaceRoot: Path,
|
||||
val paths: List<String>,
|
||||
)
|
||||
|
||||
data class LspDiagnosticsResult(
|
||||
val server: String? = null,
|
||||
val diagnostics: List<LspDiagnostic> = emptyList(),
|
||||
val skippedReason: String? = null,
|
||||
)
|
||||
|
||||
/** Nondeterministic LSP boundary. The orchestrator records its result before using it. */
|
||||
fun interface LspDiagnosticsRunner {
|
||||
suspend fun pull(request: LspDiagnosticsRequest): LspDiagnosticsResult
|
||||
}
|
||||
+2
@@ -35,6 +35,8 @@ data class OrchestrationTuning(
|
||||
val reviewBlockMinConfidence: Double = 0.7,
|
||||
/** Pathological backstop: max review-driven retries before the stage is let through. */
|
||||
val reviewBlockRetryCap: Int = 20,
|
||||
/** Review→rework cycles before deterministic escalation to recovery. */
|
||||
val reviewLoopMaxCycles: Int = 3,
|
||||
/** Max review→refine cycles for a stage (freestyle default refinement budget). */
|
||||
val defaultMaxRefinement: Int = 3,
|
||||
/** Budget for routing a failed write-less stage to a recovery stage. */
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ data class OrchestratorEngines(
|
||||
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
|
||||
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
||||
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
||||
val lspDiagnosticsRunner: LspDiagnosticsRunner? = null,
|
||||
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
|
||||
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
|
||||
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
|
||||
|
||||
+49
-9
@@ -4,6 +4,7 @@ import com.correx.core.tools.process.ChildProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.nio.file.Path
|
||||
@@ -26,31 +27,70 @@ class ProcessStaticAnalysisRunner(
|
||||
withContext(Dispatchers.IO) {
|
||||
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
|
||||
if (argv.first() == STATIC_FLOOR_COMMAND) return@withContext runStaticFloor(workingDir, argv)
|
||||
runProcess(workingDir, argv, command)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun runStaticFloor(workingDir: Path, argv: List<String>): StaticAnalysisRunResult {
|
||||
val paths = argv.dropWhile { it != "--" }.drop(1)
|
||||
if (paths.isEmpty()) return StaticAnalysisRunResult(0, "static floor skipped: no concrete files")
|
||||
val checks = paths.mapNotNull { path -> checkerFor(path)?.let { it + path } }
|
||||
if (checks.isEmpty()) {
|
||||
return StaticAnalysisRunResult(0, "static floor skipped: no resolvable checker for ${paths.joinToString()}")
|
||||
}
|
||||
val outputs = mutableListOf<String>()
|
||||
for (check in checks) {
|
||||
val result = runProcess(workingDir, check, check.joinToString(" "))
|
||||
outputs += "${check.joinToString(" ")}: ${result.output}".trim()
|
||||
if (result.exitCode != 0) return StaticAnalysisRunResult(result.exitCode, outputs.joinToString("\n"))
|
||||
}
|
||||
val skipped = paths.size - checks.size
|
||||
if (skipped > 0) outputs += "static floor skipped $skipped file(s) without a checker"
|
||||
return StaticAnalysisRunResult(0, outputs.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun checkerFor(path: String): List<String>? = when (path.substringAfterLast('.', "").lowercase()) {
|
||||
"js", "mjs", "cjs" -> listOf("node", "--check")
|
||||
"py" -> listOf("python3", "-m", "py_compile")
|
||||
"sh", "bash" -> listOf("bash", "-n")
|
||||
"rb" -> listOf("ruby", "-c")
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun runProcess(
|
||||
workingDir: Path,
|
||||
argv: List<String>,
|
||||
displayCommand: String,
|
||||
): StaticAnalysisRunResult {
|
||||
val process = runCatching {
|
||||
ChildProcess.builder(argv, workingDir.toFile()).redirectErrorStream(true).start()
|
||||
}.getOrElse {
|
||||
return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}")
|
||||
return StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$displayCommand': ${it.message}")
|
||||
}
|
||||
runCatching {
|
||||
withTimeout(timeoutMs) {
|
||||
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||
val exit = process.waitFor()
|
||||
StaticAnalysisRunResult(exit, outputDeferred.await())
|
||||
return runCatching {
|
||||
coroutineScope {
|
||||
withTimeout(timeoutMs) {
|
||||
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||
val exit = process.waitFor()
|
||||
StaticAnalysisRunResult(exit, outputDeferred.await())
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
process.destroyForcibly()
|
||||
val reason = if (it is TimeoutCancellationException) {
|
||||
"static analysis '$command' timed out after ${timeoutMs}ms"
|
||||
"static analysis '$displayCommand' timed out after ${timeoutMs}ms"
|
||||
} else {
|
||||
it.message ?: "error running '$command'"
|
||||
it.message ?: "error running '$displayCommand'"
|
||||
}
|
||||
StaticAnalysisRunResult(EXIT_NOT_RUN, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_TIMEOUT_MS = 300_000L
|
||||
const val EXIT_NOT_RUN = -1
|
||||
const val STATIC_FLOOR_COMMAND = "correx-static-floor"
|
||||
val WHITESPACE = Regex("\\s+")
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -212,6 +212,7 @@ abstract class SessionOrchestrator(
|
||||
internal val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||
internal val worldProbe: WorldProbe = engines.worldProbe
|
||||
internal val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||
internal val lspDiagnosticsRunner: LspDiagnosticsRunner? = engines.lspDiagnosticsRunner
|
||||
internal val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
||||
internal val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
|
||||
internal val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
|
||||
|
||||
+7
-4
@@ -76,7 +76,7 @@ internal suspend fun SessionOrchestrator.repairArtifact(
|
||||
if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
|
||||
emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
|
||||
val repaired = runArtifactRepairInference(
|
||||
sessionId, stageId, slot, bestCandidate, stageConfig, effectives, timeoutMs,
|
||||
sessionId, stageId, slot, bestCandidate, unresolved.detail, stageConfig, effectives, timeoutMs,
|
||||
)
|
||||
val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
|
||||
if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
|
||||
@@ -115,15 +115,18 @@ internal suspend fun SessionOrchestrator.runArtifactRepairInference(
|
||||
stageId: StageId,
|
||||
slot: TypedArtifactSlot,
|
||||
bestCandidate: String,
|
||||
validationError: String,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val schema = slot.kind.deriveJsonSchema()
|
||||
val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
|
||||
val prompt = "The previous output for the '${slot.name.value}' artifact was malformed and did not " +
|
||||
"match the required schema.\n\nMalformed output:\n$bestCandidate\n\nReturn ONLY a single JSON " +
|
||||
"object matching this schema. Do not add fields, prose, or code fences.\nSchema: $schemaJson"
|
||||
val prompt = "The previous output for the '${slot.name.value}' artifact failed schema validation.\n\n" +
|
||||
"Validation error:\n$validationError\n\nMalformed output:\n$bestCandidate\n\nFix ONLY what the " +
|
||||
"validation error names — every field must sit at the level the schema defines; do not nest a " +
|
||||
"top-level field inside another object. Return ONLY a single JSON object matching this schema. " +
|
||||
"Do not add fields, prose, or code fences.\nSchema: $schemaJson"
|
||||
val entry = ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
|
||||
+7
-3
@@ -190,9 +190,13 @@ internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(session
|
||||
/**
|
||||
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
|
||||
* run: record the questions (invariant #9 — observed at parse time), await the operator's
|
||||
* answers, record them, and return true so the caller re-runs the stage with the answers in
|
||||
* context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
|
||||
* proceeds. Returns false when there are no questions or the round budget is spent.
|
||||
* answers, and record them. The caller then ADVANCES to the next stage — the stage is not
|
||||
* re-run. The answers are session-wide events, injected into every later stage's L0 context
|
||||
* (see [buildClarificationAnswerEntries]), so the next stage acts on them directly; re-running
|
||||
* the questioning stage only restarts inference with no prior reasoning and makes it re-explore.
|
||||
* Bounded by [OrchestrationTuning.maxClarificationRounds] so retries can't re-park unboundedly.
|
||||
* Returns true when it parked and collected answers, false when there are no questions or the
|
||||
* round budget is spent.
|
||||
*/
|
||||
|
||||
internal suspend fun SessionOrchestrator.requestClarificationIfNeeded(
|
||||
|
||||
+21
-4
@@ -374,12 +374,29 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
continue
|
||||
}
|
||||
// emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
|
||||
// Capture those arguments as the artifact content and exit the loop straight to validation
|
||||
// (the post-loop capture honours this override instead of the empty assistant text).
|
||||
// Validate those arguments INLINE, like any other tool call: a valid artifact captures and
|
||||
// exits the loop; an invalid one is handed straight back into the SAME conversation as a
|
||||
// tool-result error, so the model fixes it with its full context (and CoT) intact. Breaking
|
||||
// the loop on a bad artifact would instead drop into a cold repair/retry that re-explores
|
||||
// from scratch and tends to re-make the identical schema mistake (2026-07-18).
|
||||
val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
|
||||
if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
|
||||
llmArtifactOverride = emitCall.function.arguments
|
||||
break
|
||||
val emitSlot = llmEmittedSlots.first()
|
||||
when (val res = artifactExtractionPipeline.run(emitCall.function.arguments, emitSlot.kind.deriveJsonSchema())) {
|
||||
is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
|
||||
llmArtifactOverride = res.canonicalJson.toString()
|
||||
break
|
||||
}
|
||||
is ArtifactExtractionPipeline.ExtractionResult.Unresolved -> {
|
||||
inferenceResult = pushBack(
|
||||
"ERROR: your emit_artifact call for '${emitSlot.name.value}' did not match the " +
|
||||
"schema: ${res.detail}. Call emit_artifact again with only that corrected. Every " +
|
||||
"field must sit at the level the schema defines — do not nest a top-level field " +
|
||||
"(e.g. ready, questions) inside another object.",
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
||||
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
||||
|
||||
+1
@@ -199,6 +199,7 @@ internal suspend fun SessionOrchestrator.runPostStageGates(
|
||||
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runLspDiagnostics(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
|
||||
)
|
||||
|
||||
+56
@@ -5,6 +5,7 @@ import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisFinding
|
||||
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -66,6 +67,55 @@ internal suspend fun SessionOrchestrator.runStaticAnalysis(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
internal suspend fun SessionOrchestrator.runLspDiagnostics(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val runner = lspDiagnosticsRunner ?: return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
val stagePaths = stageWrittenPaths(sessionId, stageId)
|
||||
val paths = if (stagePaths.isNotEmpty()) {
|
||||
stagePaths
|
||||
} else if (stageConfig.autoBuildGate) {
|
||||
sessionWrittenPaths(sessionId)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val result = runner.pull(LspDiagnosticsRequest(workspaceRoot, paths))
|
||||
val plannedButUnwritten = stageConfig.writeManifest.filterNot { it in paths }
|
||||
val diagnostics = result.diagnostics.filter { diagnostic ->
|
||||
diagnostic.path in paths && plannedButUnwritten.none { planned ->
|
||||
diagnostic.message.contains(planned.substringAfterLast('/'), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
emit(
|
||||
sessionId,
|
||||
LspDiagnosticsCompletedEvent(sessionId, stageId, result.server, diagnostics, result.skippedReason),
|
||||
)
|
||||
val errors = diagnostics.filter { it.severity.equals("error", ignoreCase = true) }
|
||||
if (errors.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
val detail = errors.joinToString("\n") {
|
||||
"- ${it.path}:${it.line + 1}:${it.character + 1} ${it.code.orEmpty()} ${it.message}".trim()
|
||||
}
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} has LSP diagnostics in files it wrote. Fix these before proceeding:\n$detail",
|
||||
retryable = true,
|
||||
gate = "lsp_diagnostics",
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SessionOrchestrator.sessionWrittenPaths(sessionId: SessionId): List<String> =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent }
|
||||
.filter { it.postImageHash != null }
|
||||
.map { it.path }
|
||||
.distinct()
|
||||
|
||||
/**
|
||||
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
|
||||
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
|
||||
@@ -76,6 +126,7 @@ internal suspend fun SessionOrchestrator.runStaticAnalysis(
|
||||
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
|
||||
*/
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
internal suspend fun SessionOrchestrator.runExecutionGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
@@ -93,6 +144,11 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
|
||||
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
|
||||
else -> null
|
||||
}
|
||||
// LSP pull diagnostics are the compiler-front-end/typecheck gate. Keep PROJECT bundlers and
|
||||
// TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well.
|
||||
if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null) {
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
|
||||
val runner = staticAnalysisRunner
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||
|
||||
+17
@@ -51,4 +51,21 @@ class ProcessStaticAnalysisRunnerTest {
|
||||
val result = runner.run(dir, " ")
|
||||
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static floor skips an unsupported filetype without failing`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-skip")
|
||||
dir.resolve("Main.kt").writeText("fun main() = Unit")
|
||||
val result = runner.run(dir, "correx-static-floor -- Main.kt")
|
||||
assertEquals(0, result.exitCode)
|
||||
assertTrue(result.output.contains("skipped"), "output: ${result.output}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static floor checks javascript syntax without project dependencies`() = runBlocking {
|
||||
val dir = createTempDirectory("sa-js")
|
||||
dir.resolve("broken.js").writeText("const = ;")
|
||||
val result = runner.run(dir, "correx-static-floor -- broken.js")
|
||||
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user