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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user