From e503a28db22e4280e31694cba1bd4f1f0f96ce33 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 11 Jun 2026 22:35:51 +0400 Subject: [PATCH] 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- 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. --- .../com/correx/apps/server/ServerModule.kt | 27 ++++++++++++++++++- .../apps/server/freestyle/FreestyleDriver.kt | 12 +++++++++ .../apps/server/routes/SessionRoutes.kt | 1 + .../DefaultSessionOrchestrator.kt | 9 +++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index a098d258..62f564c8 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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-`) 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) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index 2d0e12f2..09bfea91 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -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( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 1c77481f..e7164b5f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -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) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index 6563f075..7db734db 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -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()