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
@@ -8,6 +8,7 @@ import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent
@@ -155,6 +156,10 @@ class DefaultSessionOrchestrator(
}
/** Returns the cached validated artifact content for the given session + artifact id, or null if absent. */
fun validatedArtifactContent(sessionId: SessionId, artifactId: ArtifactId): String? =
artifactContentCache["${sessionId.value}:${artifactId.value}"]
suspend fun resume(
sessionId: SessionId,
graph: WorkflowGraph,
@@ -254,6 +259,33 @@ class DefaultSessionOrchestrator(
stageId: StageId,
): StepResult {
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
val stageRequestIds = events
.mapNotNull { it.payload as? ApprovalRequestedEvent }
.filter { it.stageId == stageId && it.toolName == null }
.map { it.requestId }
.toSet()
stageRequestIds.isNotEmpty() && events.any {
(it.payload as? ApprovalDecisionResolvedEvent)
?.requestId in stageRequestIds
}
}
if (!alreadyApproved) {
val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
val preview = gateArtifact
?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
val approved = requestStageApproval(ctx.sessionId, stageId, preview)
if (!approved) {
return StepResult.Terminal(
failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
)
}
}
}
while (true) {
if (isCancelled(ctx.sessionId)) {
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
@@ -250,21 +250,9 @@ abstract class SessionOrchestrator(
),
)
} ?: emptyList()
val promptEntries = stageConfig.metadata["prompt"]
?.let { path ->
val resolvedText = runCatching { promptResolver.resolve(path) }
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${it.message}",
)
}
.getOrNull()
val text = resolvedText?.takeIf { it.isNotBlank() }
?: error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"declared prompt '$path' could not be resolved",
)
val promptEntries = stageConfig.metadata["promptInline"]
?.takeIf { it.isNotBlank() }
?.let { text ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -276,7 +264,35 @@ abstract class SessionOrchestrator(
role = EntryRole.USER,
),
)
} ?: emptyList()
}
?: stageConfig.metadata["prompt"]
?.let { path ->
val resolvedText = runCatching { promptResolver.resolve(path) }
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${it.message}",
)
}
.getOrNull()
val text = resolvedText?.takeIf { it.isNotBlank() }
?: error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"declared prompt '$path' could not be resolved",
)
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = text,
sourceType = "agentPrompt",
sourceId = stageId.value,
tokenEstimate = estimateTokens(text),
role = EntryRole.USER,
),
)
}
?: emptyList()
val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
require(llmEmittedSlots.size <= 1) {
@@ -1205,6 +1221,65 @@ abstract class SessionOrchestrator(
return (content.length / 4).coerceAtLeast(1)
}
// --- stage-entry approval gate ---
/**
* Emits [OrchestrationPausedEvent] + [ApprovalRequestedEvent] for a stage flagged
* `requiresApproval`, awaits the user decision, and records it.
* Returns `true` if the run should proceed into the stage, `false` if the approval was rejected.
*/
internal suspend fun requestStageApproval(
sessionId: SessionId,
stageId: StageId,
preview: String,
): Boolean {
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val validationReportId = ValidationReportId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[requestId] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
ApprovalRequestedEvent(
requestId = requestId,
tier = Tier.T2,
validationReportId = validationReportId,
riskSummaryId = null,
riskSummary = null,
sessionId = sessionId,
stageId = stageId,
projectId = null,
preview = preview,
),
)
return try {
val decision = deferred.await()
emitDecisionResolved(
sessionId,
DomainApprovalRequest(
id = requestId,
tier = Tier.T2,
validationReportId = validationReportId,
riskSummaryId = null,
riskSummary = null,
timestamp = Clock.System.now(),
),
decision,
)
if (decision.isApproved) {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
true
} else {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
false
}
} finally {
pendingApprovals.remove(requestId)
}
}
// --- private functions ---
private suspend fun handleApproval(