feat(freestyle): return grounding-rejected plan to architect for a bounded re-run
A plan that failed grounding used to dead-end — every gate rejection in FreestyleDriver.lockAndRun was terminal, so a legitimate grounding catch (e.g. a stage declaring a PROJECT build with no manifest) left the run stuck with no retry. lockAndRun is now a gate loop: on a grounding rejection with retries left it re-runs the planning workflow from the architect stage (rerunArchitect), which emits a corrected plan, then re-gates. Other gate failures — and grounding once maxGroundingRetries is spent — stay terminal. - FreestyleDriver: gate loop + rerunArchitect/maxGroundingRetries seams; groundPlan returns findings (String?) instead of Boolean; post-grounding tail extracted to lockAndRunGrounded. - DefaultSessionOrchestrator.runFrom(startStage) + emitWorkflowStarted(startStage); run() delegates to it. Lets the re-run enter directly at architect. - buildGroundingFeedbackEntry (ContextFeedback) injects the already-recorded PlanGroundingEvaluatedEvent findings into the architect's L1 context on re-run; wired in SessionOrchestratorExecution. - Main: rerunArchitect lambda (rehydrate -> runFrom(architect) -> rehydrate). The architect stage-entry approval gate already reuses a prior APPROVED decision (alreadyApproved), so the re-run does not re-prompt the operator — added a FreestyleApprovalGateTest regression guard proving runFrom(architect) with a seeded approval emits no second request and runs straight through. Tests: FreestyleDriverTest retry-then-lock + exhaustion->reject(source=grounding); FreestyleApprovalGateTest reuse-approval guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -624,6 +624,20 @@ fun main() {
|
||||
requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) },
|
||||
toolCapabilities = toolRegistry.all().associate { it.name to it.requiredCapabilities },
|
||||
reflector = com.correx.apps.server.inference.CapabilityGapReflectorImpl(inferenceRouter),
|
||||
// Return-to-architect: re-run the planning workflow from the architect stage so it emits a
|
||||
// corrected plan after a grounding rejection. rehydrate before (architect needs the analyst's
|
||||
// dod, evicted on the planning graph's completion) and after (the fresh execution_plan is
|
||||
// evicted again when this re-run completes — lockAndRun's planContent must read it back).
|
||||
rerunArchitect = { sid ->
|
||||
orchestrator.rehydrate(sid)
|
||||
val planningGraph = workflowRegistry.find("freestyle_planning")
|
||||
?: error("freestyle_planning workflow not registered")
|
||||
val result = orchestrator.runFrom(
|
||||
sid, planningGraph, defaultOrchestrationConfig, com.correx.core.events.types.StageId("architect"),
|
||||
)
|
||||
orchestrator.rehydrate(sid)
|
||||
result
|
||||
},
|
||||
)
|
||||
// observability-spec §4: continuous health watch. Seed the monitor's last-status from the
|
||||
// recorded system-session events so a restart doesn't re-emit a degraded already in the log.
|
||||
|
||||
@@ -57,37 +57,73 @@ class FreestyleDriver(
|
||||
// Vikunja #30 part 2: bounded LLM "are you sure?" pass over capability gaps, consulted before
|
||||
// requestPlanApproval. Null = feature degrades to part-1 behavior (gaps recorded, never reflected).
|
||||
private val reflector: CapabilityGapReflector? = null,
|
||||
// Return-to-architect loop: on a grounding rejection, re-run the planning workflow from the
|
||||
// architect stage so it emits a corrected plan (the grounding findings are injected into its
|
||||
// context by buildGroundingFeedbackEntry). Null = no loop; grounding rejection is terminal.
|
||||
private val rerunArchitect: (suspend (SessionId) -> WorkflowResult)? = null,
|
||||
// Max grounding-driven architect re-runs before the plan is rejected for good.
|
||||
private val maxGroundingRetries: Int = 2,
|
||||
) {
|
||||
@Suppress("ReturnCount") // sequential gate pipeline: each gate is a guard-return, same as the engines
|
||||
suspend fun lockAndRun(sessionId: SessionId) {
|
||||
val json = planContent(sessionId) ?: run {
|
||||
log.warn("freestyle: no execution_plan content for session={}", sessionId.value)
|
||||
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
|
||||
return
|
||||
}
|
||||
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId)) }
|
||||
.getOrElse {
|
||||
log.error("freestyle: plan failed to compile: {}", it.message)
|
||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||
// Return-to-architect loop: each pass compiles + gates the current execution_plan. A grounding
|
||||
// rejection with budget left re-runs the architect (which emits a corrected plan) and loops;
|
||||
// every other gate failure — and grounding once the budget is spent — is terminal.
|
||||
var groundingRetries = 0
|
||||
while (true) {
|
||||
val json = planContent(sessionId) ?: run {
|
||||
log.warn("freestyle: no execution_plan content for session={}", sessionId.value)
|
||||
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
|
||||
return
|
||||
}
|
||||
// Deterministic lint (plan-pipeline-spec §5) before the plan is surfaced/locked: a hard
|
||||
// failure (unproduced need, trap state) means the plan would fail at runtime, so reject it
|
||||
// now rather than execute a broken plan. Soft findings are recorded for display only.
|
||||
val lint = PlanLinter.lint(graph)
|
||||
emitPlanLint(sessionId, graph.id, lint)
|
||||
if (!lint.passed) {
|
||||
val summary = lint.hardFailures.joinToString("; ") {
|
||||
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||
val graph = runCatching {
|
||||
compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId))
|
||||
}
|
||||
log.warn("freestyle: plan failed lint for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
.getOrElse {
|
||||
log.error("freestyle: plan failed to compile: {}", it.message)
|
||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||
return
|
||||
}
|
||||
// Deterministic lint (plan-pipeline-spec §5) before the plan is surfaced/locked: a hard
|
||||
// failure (unproduced need, trap state) means the plan would fail at runtime, so reject it
|
||||
// now rather than execute a broken plan. Soft findings are recorded for display only.
|
||||
val lint = PlanLinter.lint(graph)
|
||||
emitPlanLint(sessionId, graph.id, lint)
|
||||
if (!lint.passed) {
|
||||
val summary = lint.hardFailures.joinToString("; ") {
|
||||
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||
}
|
||||
log.warn("freestyle: plan failed lint for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
return
|
||||
}
|
||||
// Plan grounding (design 2026-07-15 seam 1) before lock: check the compiled plan against the
|
||||
// session's recorded workspace facts (repo map + profile commands). A build stage aimed at a
|
||||
// prerequisite nothing creates is doomed. Deterministic + pure over recorded events (#8/#9).
|
||||
val groundingFailure = groundPlan(sessionId, graph)
|
||||
if (groundingFailure != null) {
|
||||
if (groundingRetries < maxGroundingRetries && rerunArchitect != null) {
|
||||
groundingRetries++
|
||||
log.info(
|
||||
"freestyle: grounding returned plan to architect (attempt {}/{}) session={}: {}",
|
||||
groundingRetries, maxGroundingRetries, sessionId.value, groundingFailure,
|
||||
)
|
||||
// Re-run the architect stage; it sees the recorded grounding findings via
|
||||
// buildGroundingFeedbackEntry and emits a corrected plan. Then loop and re-gate.
|
||||
rerunArchitect.invoke(sessionId)
|
||||
continue
|
||||
}
|
||||
log.warn("freestyle: plan failed grounding for session={}: {}", sessionId.value, groundingFailure)
|
||||
emitRejected(sessionId, "plan failed grounding: $groundingFailure", "grounding")
|
||||
return
|
||||
}
|
||||
lockAndRunGrounded(sessionId, graph, json)
|
||||
return
|
||||
}
|
||||
// Plan grounding (design 2026-07-15 seam 1) before lock: check the compiled plan against the
|
||||
// session's recorded workspace facts (repo map + profile commands). A build stage aimed at a
|
||||
// prerequisite nothing creates is doomed — reject now so it fails at stage 0, not after
|
||||
// downstream files pile up. Deterministic + pure over recorded events (invariants #8/#9).
|
||||
if (groundPlan(sessionId, graph)) return
|
||||
}
|
||||
|
||||
/** Post-grounding tail of [lockAndRun]: capability gaps, plan-approval, lock, phase-2 handoff. */
|
||||
private suspend fun lockAndRunGrounded(sessionId: SessionId, graph: WorkflowGraph, json: String) {
|
||||
// Capability-gap detector (Vikunja #30 part 1): advisory only — recorded, never blocking.
|
||||
// A gap does not fail the gate and does not grant the missing tool (invariants #3/#4/#5).
|
||||
val gaps = CapabilityGapDetector.detect(graph, toolCapabilities)
|
||||
@@ -152,11 +188,12 @@ class FreestyleDriver(
|
||||
|
||||
/**
|
||||
* Grounds [graph] against recorded workspace facts and emits [PlanGroundingEvaluatedEvent].
|
||||
* Returns true if the plan was rejected (caller must stop before lock). Missing repo map/profile
|
||||
* (fresh session, no scan) grounds vacuously — an empty path set with the manifest-produced-by-plan
|
||||
* check still catches the "build with nothing to build" case.
|
||||
* Returns null when the plan grounds (PASS), else the findings summary — the caller decides
|
||||
* whether to return the plan to architect or reject it. Missing repo map/profile (fresh session,
|
||||
* no scan) grounds vacuously; the manifest-produced-by-plan check still catches "build with
|
||||
* nothing to build".
|
||||
*/
|
||||
private suspend fun groundPlan(sessionId: SessionId, graph: WorkflowGraph): Boolean {
|
||||
private suspend fun groundPlan(sessionId: SessionId, graph: WorkflowGraph): String? {
|
||||
val events = eventStore.read(sessionId)
|
||||
val repoMap = events.mapNotNull { it.payload as? RepoMapComputedEvent }.lastOrNull()
|
||||
val profile = events.mapNotNull { it.payload as? ProjectProfileBoundEvent }.lastOrNull()
|
||||
@@ -184,11 +221,8 @@ class FreestyleDriver(
|
||||
),
|
||||
),
|
||||
)
|
||||
if (result.verdict == PlanGroundingVerdict.PASS) return false
|
||||
val summary = result.findings.joinToString("; ")
|
||||
log.warn("freestyle: plan failed grounding for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed grounding: $summary", "grounding")
|
||||
return true
|
||||
if (result.verdict == PlanGroundingVerdict.PASS) return null
|
||||
return result.findings.joinToString("; ")
|
||||
}
|
||||
|
||||
private suspend fun emitPlanLint(sessionId: SessionId, candidateId: String, lint: PlanLintResult) {
|
||||
|
||||
@@ -6,9 +6,14 @@ import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.events.events.CapabilityGapDetectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapVerdict
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.RepoMapEntry
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.CapabilityGapReflection
|
||||
@@ -18,6 +23,8 @@ import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.UUID
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -471,4 +478,121 @@ class FreestyleDriverTest {
|
||||
assertTrue(payloads.filterIsInstance<CapabilityGapReflectedEvent>().isEmpty())
|
||||
assertEquals(1, payloads.filterIsInstance<ExecutionPlanLockedEvent>().size)
|
||||
}
|
||||
|
||||
// "apply" is scoped to frontend/** — with a scan recorded whose only path is backend/, and no
|
||||
// stage creating a frontend file, scope grounding fails (RETURN_TO_ARCHITECT). Passes lint.
|
||||
private val groundingFailPlanJson = """
|
||||
{
|
||||
"goal": "plan scoped to a path that doesn't exist",
|
||||
"stages": [
|
||||
{ "id": "analyse", "prompt": "Analyse", "produces": "patch", "needs": [], "tools": [] },
|
||||
{ "id": "apply", "prompt": "Apply", "produces": "patch", "needs": ["patch"], "tools": [],
|
||||
"touches": ["frontend/**"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "analyse", "to": "apply", "condition": { "type": "always_true" } },
|
||||
{ "from": "apply", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// Records a scan (scanned=true) whose only path is backend/, so frontend/** grounds to nothing.
|
||||
private suspend fun recordBackendScan(store: InMemoryEventStore, sessionId: SessionId) {
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = RepoMapComputedEvent(
|
||||
sessionId = sessionId,
|
||||
repoRoot = "/ws",
|
||||
entries = listOf(RepoMapEntry(path = "backend/main.kt", score = 1.0)),
|
||||
computedAt = Clock.System.now(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grounding failure returns plan to architect then locks once the re-run yields a grounded plan`(): Unit =
|
||||
runBlocking {
|
||||
val sessionId = SessionId("driver-grounding-retry-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
recordBackendScan(eventStore, sessionId)
|
||||
|
||||
var corrected = false
|
||||
var rerunInvocations = 0
|
||||
var runPhase2Invocations = 0
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
// Fails grounding until the architect re-run "fixes" it (flips to the unconstrained plan).
|
||||
planContent = { if (corrected) validPlanJson else groundingFailPlanJson },
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { sid, graph, _ ->
|
||||
runPhase2Invocations++
|
||||
WorkflowResult.Completed(sid, graph.start)
|
||||
},
|
||||
rerunArchitect = { sid ->
|
||||
rerunInvocations++
|
||||
corrected = true
|
||||
WorkflowResult.Completed(sid, com.correx.core.events.types.StageId("architect"))
|
||||
},
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
assertEquals(1, rerunInvocations, "architect should be re-run exactly once")
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
assertEquals(1, payloads.filterIsInstance<ExecutionPlanLockedEvent>().size, "corrected plan should lock")
|
||||
assertEquals(1, runPhase2Invocations, "runPhase2 should run once on the corrected plan")
|
||||
assertTrue(
|
||||
payloads.filterIsInstance<ExecutionPlanRejectedEvent>().isEmpty(),
|
||||
"no rejection once the re-run grounds",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grounding failure that never resolves is rejected with source grounding after exhausting retries`(): Unit =
|
||||
runBlocking {
|
||||
val sessionId = SessionId("driver-grounding-exhaust-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
recordBackendScan(eventStore, sessionId)
|
||||
|
||||
var rerunInvocations = 0
|
||||
var runPhase2Invocations = 0
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
planContent = { groundingFailPlanJson }, // never corrected
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { sid, graph, _ ->
|
||||
runPhase2Invocations++
|
||||
WorkflowResult.Completed(sid, graph.start)
|
||||
},
|
||||
rerunArchitect = { sid ->
|
||||
rerunInvocations++
|
||||
WorkflowResult.Completed(sid, com.correx.core.events.types.StageId("architect"))
|
||||
},
|
||||
maxGroundingRetries = 2,
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
assertEquals(2, rerunInvocations, "architect re-run should be capped at maxGroundingRetries")
|
||||
assertEquals(0, runPhase2Invocations, "runPhase2 must not run when grounding never clears")
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
assertTrue(payloads.filterIsInstance<ExecutionPlanLockedEvent>().isEmpty(), "never locks")
|
||||
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
|
||||
assertEquals("grounding", rejected.source)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user