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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user