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:
@@ -10,6 +10,8 @@ import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.PlanGroundingEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanGroundingVerdict
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
@@ -40,6 +42,35 @@ fun buildRetryFeedbackEntry(events: List<StoredEvent>, stageId: StageId): Contex
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the deterministic plan-grounding findings back into the architect stage when the freestyle
|
||||
* driver returned its plan for another attempt (grounding verdict != PASS). The findings are already
|
||||
* recorded on [PlanGroundingEvaluatedEvent] (invariant #9), so this reads them rather than re-deriving.
|
||||
* Gated to the plan-producing stage — "architect" in freestyle_planning, the same stage id the driver
|
||||
* stamps on rejection. Last event wins: a re-run that grounds PASS clears the feedback automatically.
|
||||
*/
|
||||
fun buildGroundingFeedbackEntry(events: List<StoredEvent>, stageId: StageId): ContextEntry? {
|
||||
if (stageId.value != "architect") return null
|
||||
val latest = events.mapNotNull { it.payload as? PlanGroundingEvaluatedEvent }.lastOrNull() ?: return null
|
||||
if (latest.verdict == PlanGroundingVerdict.PASS) return null
|
||||
val content = "## Plan grounding feedback\n" +
|
||||
"Your previous execution_plan was returned — it does not hold against the workspace facts:\n" +
|
||||
latest.findings.joinToString("\n") { "- $it" } + "\n" +
|
||||
"Emit a corrected plan that resolves every point above. Typical fixes: declare the manifest a " +
|
||||
"build stage needs (have an earlier stage create it via `writes`/`expectedFiles`), narrow a " +
|
||||
"stage's `touches` to paths that exist or that an earlier stage creates, or drop a build stage " +
|
||||
"that has nothing to build. Do not repeat the identical plan."
|
||||
return ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = content,
|
||||
sourceType = "groundingFeedback",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = content.length / 4,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the open failure ticket into the recovery stage's context. The recovery stage was entered
|
||||
* by kernel routing (not a normal edge) precisely because an upstream write-less stage failed a gate
|
||||
|
||||
+20
-5
@@ -131,15 +131,30 @@ class DefaultSessionOrchestrator(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
): WorkflowResult {
|
||||
log.debug("[Orchestrator] session={} workflow={} start={}", sessionId.value, graph.id, graph.start.value)
|
||||
emitWorkflowStarted(sessionId, graph, config)
|
||||
): WorkflowResult = runFrom(sessionId, graph, config, graph.start)
|
||||
|
||||
val base = ExecutionContext(graph, sessionId, 0, graph.start, config, null, null)
|
||||
/**
|
||||
* Runs [graph] entering at [startStage] instead of [graph].start. Used by the freestyle
|
||||
* return-to-architect loop to re-enter just the plan-producing stage (with grounding feedback
|
||||
* already in its L1 context) without redoing discovery/analyst. [startStage] must be in [graph].
|
||||
*/
|
||||
suspend fun runFrom(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
startStage: StageId,
|
||||
): WorkflowResult {
|
||||
require(graph.stages.containsKey(startStage)) {
|
||||
"startStage '${startStage.value}' is not a stage of workflow '${graph.id}'"
|
||||
}
|
||||
log.debug("[Orchestrator] session={} workflow={} start={}", sessionId.value, graph.id, startStage.value)
|
||||
emitWorkflowStarted(sessionId, graph, config, startStage)
|
||||
|
||||
val base = ExecutionContext(graph, sessionId, 0, startStage, config, null, null)
|
||||
val enriched = base.enrich()
|
||||
|
||||
// Execute the start stage before entering the step loop
|
||||
return when (val result = enterStage(enriched, graph.start)) {
|
||||
return when (val result = enterStage(enriched, startStage)) {
|
||||
is StepResult.Continue -> step(result.ctx)
|
||||
is StepResult.Terminal -> result.result
|
||||
}
|
||||
|
||||
+4
-1
@@ -229,6 +229,8 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
val agentInstructionsEntries = emptyList<ContextEntry>()
|
||||
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val groundingFeedbackEntries = buildGroundingFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val vocabularyEntries = artifactKindRegistry
|
||||
@@ -266,7 +268,8 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||
clarificationEntries + retryFeedbackEntries + groundingFeedbackEntries + recoveryTicketEntries +
|
||||
remainingDeltaEntries,
|
||||
)
|
||||
val contextPack = runCatching {
|
||||
contextPackBuilder.build(
|
||||
|
||||
+7
-2
@@ -69,13 +69,18 @@ internal suspend fun SessionOrchestrator.advanceStage(
|
||||
|
||||
// --- terminal state helpers ---
|
||||
|
||||
internal suspend fun SessionOrchestrator.emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) {
|
||||
internal suspend fun SessionOrchestrator.emitWorkflowStarted(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
startStage: StageId = graph.start,
|
||||
) {
|
||||
emit(
|
||||
sessionId,
|
||||
WorkflowStartedEvent(
|
||||
sessionId = sessionId,
|
||||
workflowId = graph.id,
|
||||
startStageId = graph.start,
|
||||
startStageId = startStage,
|
||||
retryPolicy = config.retryPolicy,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user