From e195c79510229d2f6ae9faf968c1c0d7d1bbed3f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 13:54:08 +0400 Subject: [PATCH] fix(server): record boot-time parking of stale sessions as OrchestrationPaused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale RUNNING session skipped by resumeAbandoned stayed RUNNING in every projection — the TUI showed a phantom running workflow that no process was executing, and each boot re-logged the same skip. Parking now emits OrchestrationPaused(reason=ABANDONED_STALE): status becomes PAUSED (truthful — not executing, resumable via POST /resume) and subsequent boots skip it on status alone. --- .../com/correx/apps/server/ServerModule.kt | 26 ++++++++++++++++++- 1 file changed, 25 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 9bfb6744..fef96231 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 @@ -18,11 +18,13 @@ 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.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent 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.events.types.StageId import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.OrchestrationConfig @@ -366,9 +368,31 @@ class ServerModule( val lastEventAt = eventStore.read(sessionId).lastOrNull()?.metadata?.timestamp ?: return@forEach if (lastEventAt < cutoff) { log.info( - "resumeAbandoned: skipping stale session={} (last event {}, cutoff {}m) — POST /resume revives it", + "resumeAbandoned: parking stale session={} (last event {}, cutoff {}m) — POST /resume revives it", sessionId.value, lastEventAt, maxAgeMinutes, ) + // Record the parking decision as an event (invariant #9): the projection + // otherwise reports RUNNING forever for a session no process is executing, + // and the next boot would re-log the same skip. + moduleScope.launch { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(java.util.UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = OrchestrationPausedEvent( + sessionId = sessionId, + stageId = orchState.currentStageId ?: StageId.NONE, + reason = "ABANDONED_STALE", + ), + ), + ) + } return@forEach } val graph = workflowRegistry.find(orchState.workflowId) ?: run {