refactor(server): single guarded freestyleHandoff shared by run and resume launchers

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.
This commit is contained in:
2026-06-12 12:32:57 +04:00
parent a0da220e8e
commit 4107a595db
@@ -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)
}