feat(freestyle): two-phase planning->execution driver (Slice 4)

- freestyle_planning.toml: analyst -> (approval) -> architect, producing
  execution_plan; analyst_freestyle.md surfaces open questions.
- TomlWorkflowLoader: requires_approval stage flag -> metadata.
- SessionOrchestrator: inline-prompt branch (metadata[promptInline]) +
  requestStageApproval helper reusing the existing pause/approve path.
- DefaultSessionOrchestrator: enterStage gates on requiresApproval
  (idempotent via resolved-approval lookup), preview derived from the
  gated stage's needs artifact; validatedArtifactContent accessor.
- FreestyleDriver: compiles validated plan, emits ExecutionPlanLockedEvent,
  runs phase 2 in-session via a runPhase2 seam; wired through ServerModule
  + Main (execution_plan kind registered).
This commit is contained in:
2026-06-08 02:50:36 +04:00
parent 6c8c5e2ad9
commit 37ac767d1f
11 changed files with 744 additions and 17 deletions
@@ -54,8 +54,12 @@ import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.toolintent.rules.ExecInterpreterRule
import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.events.types.ArtifactId
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
import com.correx.infrastructure.inference.commons.AmdResourceProbe
@@ -306,13 +310,24 @@ fun main() {
} else {
null
}
val configArtifactKinds = loadConfigArtifactKinds(correxConfig)
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
configArtifactKinds.forEach { reg.register(it) }
}
val freestyleDriver = FreestyleDriver(
eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry),
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
)
val module = ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(loadConfigArtifactKinds(correxConfig)),
InfrastructureModule.createWorkflowLoader(configArtifactKinds),
),
providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig,
@@ -326,6 +341,7 @@ fun main() {
workspaceResolver = workspaceResolver,
narrationMaxPerRun = routerConfig.narration.maxPerRun,
projectMemory = projectMemory,
freestyleDriver = freestyleDriver,
)
module.start()
log.info("==============================")
@@ -1,6 +1,7 @@
package com.correx.apps.server
import com.correx.apps.server.approval.ApprovalCoordinator
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.narration.NarrationSubscriber
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
@@ -69,6 +70,9 @@ class ServerModule(
val narrationMaxPerRun: Int = 100,
// Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
val projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
// Driver for the two-phase freestyle workflow: locks the plan and runs phase 2.
// Null on non-freestyle paths; injected by Main when freestyle is configured.
private val freestyleDriver: FreestyleDriver? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -139,6 +143,10 @@ class ServerModule(
}
runCatching {
orchestrator.run(sessionId, graph, sessionConfig)
// After a successful freestyle planning run, lock the plan and execute phase 2.
if (graph.id == "freestyle_planning") {
freestyleDriver?.lockAndRun(sessionId)
}
// Distil this run's decisions into durable project memory on completion.
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
}.onFailure { ex ->
@@ -0,0 +1,67 @@
package com.correx.apps.server.freestyle
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
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.OrchestrationConfig
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID
/**
* Drives the second phase of a freestyle workflow: reads the validated execution_plan,
* compiles it, emits [ExecutionPlanLockedEvent], and invokes phase-2 orchestration.
*
* [runPhase2] is a functional seam so the driver can be tested without the full
* [DefaultSessionOrchestrator] constructor chain.
*/
class FreestyleDriver(
private val eventStore: EventStore,
private val compiler: ExecutionPlanCompiler,
private val planContent: (SessionId) -> String?,
private val config: OrchestrationConfig,
private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult,
) {
suspend fun lockAndRun(sessionId: SessionId) {
val json = planContent(sessionId) ?: run {
log.warn("freestyle: no execution_plan content for session={}", sessionId.value)
return
}
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
.getOrElse {
log.error("freestyle: plan failed to compile: {}", it.message)
return
}
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ExecutionPlanLockedEvent(
sessionId = sessionId,
planArtifactId = ArtifactId("execution_plan"),
workflowId = graph.id,
stageIds = graph.stageIds.map { it.value },
startStageId = graph.start.value,
),
),
)
runPhase2(sessionId, graph, config)
}
companion object {
private val log = LoggerFactory.getLogger(FreestyleDriver::class.java)
}
}
@@ -0,0 +1,157 @@
package com.correx.apps.server.freestyle
import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FreestyleDriverTest {
private val validPlanJson = """
{
"goal": "test plan",
"stages": [
{
"id": "analyse",
"prompt": "Analyse the problem",
"produces": "patch",
"needs": [],
"tools": []
},
{
"id": "apply",
"prompt": "Apply the fix",
"produces": "patch",
"needs": ["patch"],
"tools": []
}
],
"edges": [
{
"from": "analyse",
"to": "apply",
"condition": { "type": "always_true" }
},
{
"from": "apply",
"to": "done",
"condition": { "type": "always_true" }
}
]
}
""".trimIndent()
private val malformedPlanJson = """{ "not": "a valid plan" }"""
private fun buildRegistry(): DefaultArtifactKindRegistry =
DefaultArtifactKindRegistry().also {
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
}
@Test
fun `lockAndRun emits ExecutionPlanLockedEvent with compiled stageIds and invokes runPhase2`(): Unit = runBlocking {
val sessionId = SessionId("driver-test-session")
val eventStore = InMemoryEventStore()
val registry = buildRegistry()
val compiler = ExecutionPlanCompiler(registry)
var runPhase2Invocations = 0
var capturedGraph: WorkflowGraph? = null
val driver = FreestyleDriver(
eventStore = eventStore,
compiler = compiler,
planContent = { validPlanJson },
config = OrchestrationConfig(),
runPhase2 = { sid, graph, _ ->
runPhase2Invocations++
capturedGraph = graph
WorkflowResult.Completed(sid, graph.start)
},
)
driver.lockAndRun(sessionId)
val lockedEvents = eventStore.read(sessionId)
.map { it.payload }
.filterIsInstance<ExecutionPlanLockedEvent>()
assertEquals(1, lockedEvents.size, "Expected exactly one ExecutionPlanLockedEvent")
val locked = lockedEvents.single()
assertEquals(setOf("analyse", "apply"), locked.stageIds.toSet())
assertEquals("analyse", locked.startStageId)
assertTrue(locked.workflowId.startsWith("freestyle-"))
assertEquals(1, runPhase2Invocations, "runPhase2 should be called exactly once")
assertEquals(setOf("analyse", "apply"), capturedGraph?.stageIds?.map { it.value }?.toSet())
}
@Test
fun `lockAndRun with malformed plan emits no lock event and does not invoke runPhase2`(): Unit = runBlocking {
val sessionId = SessionId("driver-malformed-session")
val eventStore = InMemoryEventStore()
val registry = buildRegistry()
val compiler = ExecutionPlanCompiler(registry)
var runPhase2Invocations = 0
val driver = FreestyleDriver(
eventStore = eventStore,
compiler = compiler,
planContent = { malformedPlanJson },
config = OrchestrationConfig(),
runPhase2 = { _, _, _ ->
runPhase2Invocations++
WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done"))
},
)
driver.lockAndRun(sessionId)
val lockedEvents = eventStore.read(sessionId)
.map { it.payload }
.filterIsInstance<ExecutionPlanLockedEvent>()
assertTrue(lockedEvents.isEmpty(), "No lock event expected for malformed plan")
assertEquals(0, runPhase2Invocations, "runPhase2 must not be called for malformed plan")
}
@Test
fun `lockAndRun with absent plan content does not emit lock event or invoke runPhase2`(): Unit = runBlocking {
val sessionId = SessionId("driver-absent-session")
val eventStore = InMemoryEventStore()
val registry = buildRegistry()
val compiler = ExecutionPlanCompiler(registry)
var runPhase2Invocations = 0
val driver = FreestyleDriver(
eventStore = eventStore,
compiler = compiler,
planContent = { null },
config = OrchestrationConfig(),
runPhase2 = { _, _, _ ->
runPhase2Invocations++
WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done"))
},
)
driver.lockAndRun(sessionId)
val lockedEvents = eventStore.read(sessionId)
.map { it.payload }
.filterIsInstance<ExecutionPlanLockedEvent>()
assertTrue(lockedEvents.isEmpty(), "No lock event when plan content is absent")
assertEquals(0, runPhase2Invocations, "runPhase2 must not be called when plan is absent")
}
}