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:
2026-06-10 11:51:47 +04:00
parent bc83a2d64e
commit bb70c94a99
9 changed files with 459 additions and 4 deletions
@@ -0,0 +1,45 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A confirmed preemptive redirect (freestyle graph re-routing): the run should jump from
* [fromStageId] to [toStageId] at the next transition boundary, overriding the resolver's edge.
*
* This is the **recorded, deterministic** half of the hybrid design — an LLM proposes a target
* from a free-text steering note and the operator confirms it (via the approval gate), but only
* the confirmed [toStageId] is recorded here. Replay reads this target and never re-classifies
* (invariants #8/#9). [sourceNoteId] links back to the originating steering note when present.
*
* Consumed exactly once: the override applies it and records [redirectId] on the resulting
* [TransitionExecutedEvent], so a replay sees it already applied.
*/
@Serializable
@SerialName("PreemptRedirect")
data class PreemptRedirectEvent(
val redirectId: String,
val sessionId: SessionId,
val fromStageId: StageId,
val toStageId: StageId,
val sourceNoteId: String? = null,
val timestampMs: Long,
) : EventPayload
/**
* A preemptive redirect that failed the safety guard — the target stage is unknown/terminal or
* its `needs` artifacts aren't satisfied (decision: invalid jumps are blocked). The run continues
* on its normally-resolved edge. Recording this also marks [redirectId] consumed, so the blocked
* redirect is not retried on every subsequent boundary.
*/
@Serializable
@SerialName("PreemptRedirectBlocked")
data class PreemptRedirectBlockedEvent(
val redirectId: String,
val sessionId: SessionId,
val toStageId: StageId,
val reason: String,
val timestampMs: Long,
) : EventPayload
@@ -29,5 +29,9 @@ data class TransitionExecutedEvent(
val sessionId: SessionId,
val from: StageId,
val to: StageId,
val transitionId: TransitionId
val transitionId: TransitionId,
// Set when this transition was produced by a preemptive redirect override (freestyle
// graph re-routing). Carries the originating PreemptRedirectEvent.redirectId — this is the
// durable "redirect consumed" marker the override reads to apply each redirect exactly once.
val redirectId: String? = null,
) : EventPayload
@@ -33,6 +33,8 @@ import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
@@ -63,6 +65,8 @@ val eventModule = SerializersModule {
subclass(ToolCallAssessedEvent::class)
subclass(FileWrittenEvent::class)
subclass(SteeringNoteAddedEvent::class)
subclass(PreemptRedirectEvent::class)
subclass(PreemptRedirectBlockedEvent::class)
subclass(InitialIntentEvent::class)
subclass(StageFailedEvent::class)
subclass(StageCompletedEvent::class)
@@ -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 }
}
}
@@ -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
}
@@ -7,7 +7,11 @@ sealed interface TransitionDecision {
data class Move(
val transitionId: TransitionId,
val to: StageId
val to: StageId,
// Set only when this Move was produced by a kernel-level preemptive redirect override
// (freestyle graph re-routing). The pure resolver never sets it; it threads to
// TransitionExecutedEvent.redirectId as the "redirect consumed" marker.
val redirectId: String? = null,
) : TransitionDecision
data object Stay : TransitionDecision