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:
@@ -144,6 +144,42 @@ class FreestyleApprovalGateTest {
|
||||
start = analystStage,
|
||||
)
|
||||
|
||||
/** Seeds a prior architect-gate request + resolved decision (the retry/resume situation). */
|
||||
private suspend fun seedArchitectDecision(sessionId: SessionId, outcome: ApprovalOutcome) {
|
||||
val requestId = com.correx.core.events.types.ApprovalRequestId("seeded-req-${outcome.name}")
|
||||
eventStore.append(
|
||||
com.correx.testing.fixtures.EventFixtures.newEvent(
|
||||
com.correx.core.events.types.EventId("seed-req-${outcome.name}"),
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T2,
|
||||
validationReportId = com.correx.core.events.types.ValidationReportId("vr"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = architectStage,
|
||||
projectId = null,
|
||||
toolName = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
eventStore.append(
|
||||
com.correx.testing.fixtures.EventFixtures.newEvent(
|
||||
com.correx.core.events.types.EventId("seed-dec-${outcome.name}"),
|
||||
sessionId,
|
||||
com.correx.core.events.events.ApprovalDecisionResolvedEvent(
|
||||
decisionId = com.correx.core.events.types.ApprovalDecisionId("seeded-dec-${outcome.name}"),
|
||||
requestId = requestId,
|
||||
outcome = outcome,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = Tier.T2,
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private val analysisSummary = """{"summary":"open questions: feasibility of X, timeline for Y"}"""
|
||||
private val executionPlanJson = """{"plan":"step 1: do A, step 2: do B"}"""
|
||||
|
||||
@@ -274,6 +310,52 @@ class FreestyleApprovalGateTest {
|
||||
runJob.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runFrom architect reuses a prior APPROVED decision and does not re-prompt on grounding retry`(): Unit =
|
||||
runBlocking {
|
||||
// Return-to-architect loop (FreestyleDriver.rerunArchitect): a grounding-rejected plan re-runs
|
||||
// the architect via runFrom(architect). The operator already approved the analyst→architect
|
||||
// gate on the first pass, so the re-run must NOT re-park — the seeded APPROVED decision below
|
||||
// stands in for that first approval; the gate must reuse it.
|
||||
val sessionId = SessionId("freestyle-gate-rerun")
|
||||
seedArchitectDecision(sessionId, ApprovalOutcome.APPROVED)
|
||||
|
||||
// Provider always yields the execution plan — we enter at architect, not analyst.
|
||||
val router = object : com.correx.core.inference.InferenceRouter {
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) =
|
||||
MockInferenceProvider(fixedResponse = executionPlanJson)
|
||||
}
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = OrchestratorEngines(
|
||||
transitionResolver = TransitionFixtures.simpleResolver(),
|
||||
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
||||
inferenceRouter = router,
|
||||
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
||||
approvalEngine = DefaultApprovalEngine(),
|
||||
riskAssessor = DefaultRiskAssessor(),
|
||||
),
|
||||
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||
artifactStore = NoopArtifactStore(),
|
||||
decisionJournalRepository = decisionJournalRepository,
|
||||
)
|
||||
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||
|
||||
orchestrator.runFrom(sessionId, freestyleGraph(), config, architectStage)
|
||||
|
||||
val architectRequests = eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? ApprovalRequestedEvent }
|
||||
.count { it.stageId == architectStage && it.toolName == null }
|
||||
assertTrue(
|
||||
architectRequests == 1,
|
||||
"gate must reuse the prior approval, not re-prompt: got $architectRequests",
|
||||
)
|
||||
assertNotNull(
|
||||
eventStore.read(sessionId).find { it.payload is WorkflowCompletedEvent },
|
||||
"architect should run straight through on the reused approval",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("freestyle-gate-2")
|
||||
|
||||
Reference in New Issue
Block a user