From 68c56b6af6d5161d821e9e950fae0de8d62a00ab Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 19 Jul 2026 03:00:35 +0400 Subject: [PATCH] feat(freestyle): return grounding-rejected plan to architect for a bounded re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/com/correx/apps/server/Main.kt | 14 ++ .../apps/server/freestyle/FreestyleDriver.kt | 100 +++++++++----- .../server/freestyle/FreestyleDriverTest.kt | 124 ++++++++++++++++++ .../kernel/orchestration/ContextFeedback.kt | 31 +++++ .../DefaultSessionOrchestrator.kt | 25 +++- .../SessionOrchestratorExecution.kt | 5 +- .../SessionOrchestratorWorkflow.kt | 9 +- .../test/kotlin/FreestyleApprovalGateTest.kt | 82 ++++++++++++ 8 files changed, 349 insertions(+), 41 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index c341680e..2099075b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index 99b442cf..bbb904e5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -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) { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt index 43f99e02..2c4567f9 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt @@ -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().isEmpty()) assertEquals(1, payloads.filterIsInstance().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().size, "corrected plan should lock") + assertEquals(1, runPhase2Invocations, "runPhase2 should run once on the corrected plan") + assertTrue( + payloads.filterIsInstance().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().isEmpty(), "never locks") + val rejected = payloads.filterIsInstance().single() + assertEquals("grounding", rejected.source) + } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 83684a0b..278bc272 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -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, 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, 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 diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index 1a194818..4b805c1e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -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 } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 337e388e..145100db 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -229,6 +229,8 @@ internal suspend fun SessionOrchestrator.executeStage( val agentInstructionsEntries = emptyList() 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( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt index 1295c77f..d083a4a6 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt @@ -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, ), ) diff --git a/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt b/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt index 797a6696..2d0607c9 100644 --- a/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt +++ b/testing/integration/src/test/kotlin/FreestyleApprovalGateTest.kt @@ -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) = + 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")