feat(kernel,events): freestyle graph re-routing — deterministic override core
Implements the replay-safe half of preemptive redirect (graph re-routing). An operator-confirmed PreemptRedirectEvent overrides the resolver's edge at the next transition boundary; the pure resolver stays untouched. - PreemptRedirectEvent / PreemptRedirectBlockedEvent (+ serialization registration) - TransitionExecutedEvent + TransitionDecision.Move gain optional redirectId — the durable 'consumed once' marker - PreemptRedirect.decide: pure decision over the event log (override / block / none), so replay reaches the identical outcome without re-classifying (invariant #8) - override wired in DefaultSessionOrchestrator.step above resolveTransition; back-edge jumps count against the existing maxRetries cap via executeMove - blocks jumps to unknown stages or targets with unsatisfied needs - DomainEventMapper: redirect events mapped to null (operator surface ships with the LLM-proposal + approval-confirm front-half) - tests: override+consume-once, block-on-needs, block-on-unknown, ignore-consumed Front-half (LLM proposal -> approval-gate confirm -> emit PreemptRedirectEvent) is deferred; needs live LLM verification. Until it ships no redirect event is emitted in production, so the override is inert. Spec: docs/plans/2026-06-10-freestyle-graph-rerouting.md
This commit is contained in:
+28
-1
@@ -118,9 +118,36 @@ class DefaultSessionOrchestrator(
|
||||
artifactContentCache[cacheKey]?.let { content -> id to content }
|
||||
}.toMap()
|
||||
|
||||
val decision = resolveTransition(
|
||||
val resolved = resolveTransition(
|
||||
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
|
||||
)
|
||||
// Preemptive redirect override (freestyle graph re-routing): if the operator confirmed a
|
||||
// jump (a recorded, unconsumed PreemptRedirectEvent), it overrides the resolver's edge —
|
||||
// unless the target is unknown or its needs aren't satisfied, in which case it is blocked
|
||||
// and the normal edge is taken. The decision is pure over the event log (replay-deterministic);
|
||||
// only the block-event emission is a side effect here.
|
||||
val decision = when (
|
||||
val outcome = PreemptRedirect.decide(
|
||||
events = repositories.eventStore.read(enriched.sessionId),
|
||||
graph = enriched.graph,
|
||||
sessionId = enriched.sessionId,
|
||||
artifactAvailable = { id ->
|
||||
!artifactContentCache["${enriched.sessionId.value}:${id.value}"].isNullOrBlank()
|
||||
},
|
||||
nowMs = Clock.System.now().toEpochMilliseconds(),
|
||||
)
|
||||
) {
|
||||
is PreemptRedirect.Outcome.Override -> outcome.move
|
||||
is PreemptRedirect.Outcome.Block -> {
|
||||
emit(enriched.sessionId, outcome.event)
|
||||
log.info(
|
||||
"[Orchestrator] redirect blocked session={} to={} reason={}",
|
||||
enriched.sessionId.value, outcome.event.toStageId.value, outcome.event.reason,
|
||||
)
|
||||
resolved
|
||||
}
|
||||
PreemptRedirect.Outcome.None -> resolved
|
||||
}
|
||||
log.debug(
|
||||
"[Orchestrator] transition session={} stage={} decision={}",
|
||||
enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.PreemptRedirectBlockedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
|
||||
/**
|
||||
* Pure decision logic for freestyle graph re-routing (the override half). Given the durable event
|
||||
* log, it decides whether an operator-confirmed [PreemptRedirectEvent] should override the
|
||||
* resolver's edge. Reads only events + the graph + an artifact-availability predicate, so the
|
||||
* orchestrator reaches the identical decision on replay (invariant #8). Side effects (emitting the
|
||||
* block event, executing the move) stay in the orchestrator.
|
||||
*/
|
||||
internal object PreemptRedirect {
|
||||
|
||||
sealed interface Outcome {
|
||||
/** Override the resolver: jump to the confirmed target. */
|
||||
data class Override(val move: TransitionDecision.Move) : Outcome
|
||||
/** Target is unusable (unknown/unsatisfied needs): record this and keep the normal edge. */
|
||||
data class Block(val event: PreemptRedirectBlockedEvent) : Outcome
|
||||
/** No pending redirect — use the resolver's decision unchanged. */
|
||||
data object None : Outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* @param artifactAvailable true if the given artifact has usable (non-blank) content for this
|
||||
* session — used to gate the target stage's `needs`.
|
||||
*/
|
||||
fun decide(
|
||||
events: List<StoredEvent>,
|
||||
graph: WorkflowGraph,
|
||||
sessionId: SessionId,
|
||||
artifactAvailable: (ArtifactId) -> Boolean,
|
||||
nowMs: Long,
|
||||
): Outcome {
|
||||
val consumed = events.mapNotNull {
|
||||
when (val p = it.payload) {
|
||||
is TransitionExecutedEvent -> p.redirectId
|
||||
is PreemptRedirectBlockedEvent -> p.redirectId
|
||||
else -> null
|
||||
}
|
||||
}.toSet()
|
||||
val pending = events
|
||||
.mapNotNull { it.payload as? PreemptRedirectEvent }
|
||||
.lastOrNull { it.redirectId !in consumed }
|
||||
?: return Outcome.None
|
||||
|
||||
val reason = blockReason(graph, pending.toStageId, artifactAvailable)
|
||||
return if (reason != null) {
|
||||
Outcome.Block(
|
||||
PreemptRedirectBlockedEvent(
|
||||
redirectId = pending.redirectId,
|
||||
sessionId = sessionId,
|
||||
toStageId = pending.toStageId,
|
||||
reason = reason,
|
||||
timestampMs = nowMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Outcome.Override(
|
||||
TransitionDecision.Move(
|
||||
transitionId = TransitionId("redirect:${pending.redirectId}"),
|
||||
to = pending.toStageId,
|
||||
redirectId = pending.redirectId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable reason the target is unusable, or null if the jump is valid. */
|
||||
private fun blockReason(
|
||||
graph: WorkflowGraph,
|
||||
target: StageId,
|
||||
artifactAvailable: (ArtifactId) -> Boolean,
|
||||
): String? {
|
||||
val stage = graph.stages[target] ?: return "unknown stage '${target.value}'"
|
||||
val unmet = stage.needs.filterNot(artifactAvailable)
|
||||
return if (unmet.isEmpty()) null
|
||||
else "target '${target.value}' has unsatisfied needs: " + unmet.joinToString(", ") { it.value }
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1370,7 +1370,10 @@ abstract class SessionOrchestrator(
|
||||
fromStageId: StageId,
|
||||
decision: TransitionDecision.Move,
|
||||
): StageId {
|
||||
emit(sessionId, TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId))
|
||||
emit(
|
||||
sessionId,
|
||||
TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId, decision.redirectId),
|
||||
)
|
||||
return decision.to
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user