From 4107a595db5c904533750ae83d5b6e9583f6479a Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 12:32:57 +0400 Subject: [PATCH] refactor(server): single guarded freestyleHandoff shared by run and resume launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchSessionRun and launchSessionResumeWithRehydrate each inlined their own copy of the freestyle phase-2 handoff with different guard sets — the run path had none, so a failed planning run still called lockAndRun and emitted a spurious ExecutionPlanRejected. Both now call one handoff that checks graph id, planning result, and an existing plan lock before locking and running phase 2. --- .../com/correx/apps/server/ServerModule.kt | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) 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 d005f0e6..22f3048b 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 @@ -225,11 +225,8 @@ class ServerModule( } bindProjectProfile(sessionId) 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) - } + val result = orchestrator.run(sessionId, graph, sessionConfig) + freestyleHandoff(sessionId, graph, result) // Distil this run's decisions into durable project memory on completion. projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) } // Propose learned profile adaptations based on session journal (opt-in, never auto-applied). @@ -309,6 +306,25 @@ class ServerModule( ) } + /** + * Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a + * planning graph (fresh run and all resume paths). Locks the plan and executes it, + * guarded so a failed planning run never emits a spurious ExecutionPlanRejected and + * a session whose plan is already locked never double-locks. + */ + private suspend fun freestyleHandoff( + sessionId: SessionId, + graph: com.correx.core.transitions.graph.WorkflowGraph, + result: WorkflowResult, + ) { + if (graph.id != "freestyle_planning") return + if (result !is WorkflowResult.Completed) return + val planAlreadyLocked = eventStore.read(sessionId) + .any { it.payload is ExecutionPlanLockedEvent } + if (planAlreadyLocked) return + freestyleDriver?.lockAndRun(sessionId) + } + /** * Rehydrates [artifactContentCache] from durable events and then resumes the session. * Used by [POST /sessions/{id}/resume], boot-time abandoned-session pickup, and the @@ -322,17 +338,7 @@ class ServerModule( runCatching { orchestrator.rehydrate(sessionId) 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) - } + freestyleHandoff(sessionId, graph, result) }.onFailure { e -> log.error("resumeSession (rehydrate): session={} failed", sessionId.value, e) }