fix(server,kernel): freestyle survives kill/restart at every phase (B4)

Three gaps found by live QA (kill between planning-complete and plan lock):
- launchSessionResumeWithRehydrate never handed off to FreestyleDriver, so a
  resumed freestyle session stranded at planning-complete with no plan lock
  and no phase 2; now locks+runs when planning completed and no
  ExecutionPlanLockedEvent exists yet.
- resume route 400'd on phase-2 sessions (workflowId freestyle-<sid> is
  compiled, never registered); now recompiles the locked plan after
  rehydrating the artifact cache.
- orchestrator.resume threw when currentStageId was terminal ('done') or
  unknown to the graph; now returns WorkflowResult.Completed.
This commit is contained in:
2026-06-11 22:35:51 +04:00
parent 99bca1703b
commit e503a28db2
4 changed files with 48 additions and 1 deletions
@@ -15,6 +15,7 @@ import com.correx.core.config.ProjectProfileLoader
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.OrchestrationResumedEvent
@@ -22,6 +23,7 @@ import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.stores.EventStore
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.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationRepository
@@ -263,6 +265,18 @@ class ServerModule(
}
}
/**
* Resolves the workflow graph for resuming a freestyle phase-2 session. Phase-2 graphs
* (`freestyle-<sessionId>`) are compiled from the locked plan, never registered in the
* TOML registry, so the resume route cannot find them there. Rehydrates the artifact
* cache first because the plan content lives in it after a restart.
*/
suspend fun freestyleResumeGraph(sessionId: SessionId, workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? {
if (!workflowId.startsWith("freestyle-")) return null
orchestrator.rehydrate(sessionId)
return freestyleDriver?.compiledGraph(sessionId)
}
/**
* Bind the per-repo project profile (curated .correx/project.toml) as an event so stages,
* router chat triage, and replay read the recorded snapshot, never the live file
@@ -318,7 +332,18 @@ class ServerModule(
moduleScope.launch {
runCatching {
orchestrator.rehydrate(sessionId)
orchestrator.resume(sessionId, graph, orchestrationConfig())
val result = orchestrator.resume(sessionId, graph, orchestrationConfig())
// Freestyle handoff parity with launchSessionRun: a session killed between
// planning completion and plan lock would otherwise strand forever — the
// resume completes the planning graph but nothing ever locks the plan.
val planAlreadyLocked = eventStore.read(sessionId)
.any { it.payload is ExecutionPlanLockedEvent }
if (graph.id == "freestyle_planning" &&
result is WorkflowResult.Completed &&
!planAlreadyLocked
) {
freestyleDriver?.lockAndRun(sessionId)
}
}.onFailure { e ->
log.error("resumeSession (rehydrate): session={} failed", sessionId.value, e)
}
@@ -71,6 +71,18 @@ class FreestyleDriver(
runPhase2(sessionId, graph, config)
}
/**
* Recompiles the stored execution plan for resume-after-restart. Requires the
* orchestrator's artifact cache to be rehydrated first. Null when the plan is
* absent or no longer compiles — callers fall back to a route-level error.
*/
fun compiledGraph(sessionId: SessionId): WorkflowGraph? {
val json = planContent(sessionId) ?: return null
return runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
.onFailure { log.error("freestyle: plan recompile for resume failed: {}", it.message) }
.getOrNull()
}
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
eventStore.append(
NewEvent(
@@ -129,6 +129,7 @@ private fun Route.resumeSessionRoute(module: ServerModule) {
val workflowId = orchState.workflowId.takeIf { it.isNotBlank() }
?: return@post call.respond(HttpStatusCode.BadRequest, "Session has no workflowId")
val graph = module.workflowRegistry.find(workflowId)
?: module.freestyleResumeGraph(TypeId(id), workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: $workflowId")
module.launchSessionResumeWithRehydrate(TypeId(id), graph)
call.respond(HttpStatusCode.Accepted)
@@ -219,6 +219,15 @@ class DefaultSessionOrchestrator(
): WorkflowResult {
val stageId = orchestrationRepository.getState(sessionId).currentStageId
?: return WorkflowResult.Failed(sessionId, "resume: no currentStageId", retryExhausted = false)
if (!graph.stages.containsKey(stageId)) {
// Terminal pseudo-stage ("done") or a stage from another graph — nothing to
// re-enter. Treat as already completed instead of throwing inside enterStage.
log.info(
"[Orchestrator] resume: session={} stage={} not in graph '{}' — already terminal",
sessionId.value, stageId.value, graph.id,
)
return WorkflowResult.Completed(sessionId, stageId)
}
log.info("[Orchestrator] resuming session={} workflow={} stage={}", sessionId.value, graph.id, stageId.value)
val base = ExecutionContext(graph, sessionId, 0, stageId, config, null, null)
val enriched = base.enrich()