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:
+32
@@ -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))
|
||||
|
||||
+91
-16
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user