feat(freestyle): soft-lock steering preemption (Slice 5)

- DecisionKind gains PREEMPT; DecisionJournalState tracks planLocked.
- DefaultDecisionJournalReducer: ExecutionPlanLockedEvent flips planLocked
  (no record); post-lock steering notes recorded as PREEMPT with a
  'PRIORITY DIRECTIVE' summary, pre-lock stay STEERING.
- SessionOrchestrator.buildSteeringNoteEntries: post-lock notes surfaced as
  pinned L0/SYSTEM 'PRIORITY DIRECTIVE' entries ahead of the plan; pre-lock
  keep L2. Graph re-routing intentionally deferred (priority surfacing only).
- Tests: PreemptSteeringJournalTest, SteeringPreemptionTest.
This commit is contained in:
2026-06-08 02:58:21 +04:00
parent 37ac767d1f
commit 4d1b4ffbb6
6 changed files with 314 additions and 5 deletions
@@ -38,6 +38,7 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
@@ -787,12 +788,15 @@ abstract class SessionOrchestrator(
private suspend fun buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> {
val events = eventStore.read(sessionId)
var locked = false
return events.mapNotNull { event ->
when (val p = event.payload) {
is ExecutionPlanLockedEvent -> { locked = true; null }
is SteeringNoteAddedEvent -> ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
content = p.content,
layer = if (locked) ContextLayer.L0 else ContextLayer.L2,
content = if (locked) "PRIORITY DIRECTIVE — overrides the locked plan: ${p.content}"
else p.content,
sourceType = "steeringNote",
sourceId = p.stageId?.value ?: sessionId.value,
tokenEstimate = estimateTokens(p.content),