10 Commits

Author SHA1 Message Date
kami d69cb12ce9 refactor: decomposition WIP (orchestrator/server/execution-plan)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhFXmKe4WisSSPf9LrmmTg
2026-07-15 13:17:01 +04:00
kami 9b925e141d test(gate): expect uniform tool-result frame in compressed-context assertion
The tool-output framing feature (2912799f) wraps every tool result in a
'[tool exit=N]' header before it enters context. Compression still runs
(blank lines stripped); the test predated the frame and asserted the bare
compressed body. Update to the framed contract — invariant #6 unchanged.
2026-07-13 12:12:19 +04:00
kami 5d1f2ab360 chore(detekt): ratchet maxIssues 120→90 after orchestrator decomposition
kernel dropped to 85 findings (was the binding module >120 pre-split);
90 leaves ~5 slack so new violations trip the build.
2026-07-13 12:09:41 +04:00
kami 1f794dad63 refactor(kernel): decompose DefaultSessionOrchestrator into per-concern extensions
Moves the 11 private step/recovery helpers off the concrete orchestrator into
internal extension-fun files (Step: step, executeMove, enterStage,
decideGateExhaustion; Recovery: retry/route/ticket helpers). Class keeps the
override seams (run, cancel, submitApprovalDecision), the cross-module public API
(rehydrate, resume, submitClarification, submitSteering), enrich, and
validatedArtifactContent. File-private helpers the moved funs need are promoted
to internal. Pure relocation; clears TooManyFunctions on the class. kernel +
testing:kernel green; server/cli compile clean.
2026-07-13 12:06:20 +04:00
kami 670e0c4828 refactor(kernel): decompose SessionOrchestrator god-class into per-concern extensions
Splits the 3.7k-line SessionOrchestrator into the state-owning abstract class
(fields + open/abstract seams: run, cancel, runInference, mapValidationOutcome,
estimateTokens) plus behavior-preserving internal extension-fun files grouped by
concern: Artifacts, ToolExec, Workspace, Context, RepoContext, Gates, Gates2,
Workflow, Approval, Preview. Two public members consumed cross-module
(liveClarificationRequestIds, requestPlanApproval) stay on the class. Each file
kept <=10 top-level funs; pure relocation, no logic changes.

Clears LargeClass; adds no new TooManyFunctions. kernel test + detekt green;
server/cli compile clean.
2026-07-13 11:58:44 +04:00
kami 7f820bafe6 refactor(kernel): extract executeStage to extension, promote members internal
Pilot for the SessionOrchestrator god-class decomposition. Moves executeStage
(426-line stage-execution driver) out to SessionOrchestratorExecution.kt as an
internal extension fun, hoists three nested holders (RunEffectives,
ArtifactLadderOutcome, RenderedToolResult) to top-level in
SessionOrchestratorTypes.kt, and promotes class/protected members to internal so
extensions can reach them. Behavior-preserving relocation; kernel+test+server
compile green.
2026-07-13 11:44:04 +04:00
kami aee2e67c66 refactor(detekt): extract magic-number constants, wrap long lines, drop legit suppressions
Mechanical detekt cleanup across apps/core/infrastructure (behavior-preserving):
- MagicNumber 62->10: named constants + removed 'legit' MagicNumber suppressions
  (Tier level from ordinal, ReplayInferenceProvider CHARS_PER_TOKEN, ConfigLoader
  DEFAULT_* consts, capability scores, HTTP ranges, column widths, etc.)
- MaxLineLength cut to ~0 in production modules (line wraps, no logic change)
- Dropped one dead parameter (ConfigLoader.parseArray lineNum)

Structural findings (ReturnCount/LongMethod/Complexity/LargeClass) left for a
deliberate decomposition pass. Full build + detekt gate green.
2026-07-12 23:12:28 +04:00
kami 135a34eb2f merge: land vikunja-tasks audit fixes + tool-output/sampling/config work 2026-07-12 22:46:02 +04:00
kami 2912799fe0 feat(tools): bound+frame tool results, spill full output to CAS, tool_output retrieval
Tool results injected into model context are now consistently framed and
globally bounded. Success output over the floor (TOOL_RESULT_MAX_CHARS=8000)
is head/tail-truncated with a marker naming a retrieval ref; the full raw
output spills to the artifact store (CAS) and its hash is recorded on the
ToolReceipt (fullOutputHash) in the event log. Agents recover the full text
via the new read-only tool_output(ref=...) tool.

- SessionOrchestrator: frameTruncatedToolResult + renderToolResult; char-cap
  head/tail so a single pathological long line can't defeat the bound.
- ToolReceipt.fullOutputHash (additive, nullable).
- ToolOutputTool (Tier T1, no fs capability) resolves ref -> full bytes.
- Wired via extraTools in Main.kt; tool_output added to ALWAYS_AVAILABLE_READ_TOOLS.
- Failure path keeps ERROR:/FATAL: prefixes (all-rejected breaker dependency).

Tests: FrameTruncatedToolResultTest, ToolOutputToolTest.
2026-07-12 19:46:02 +04:00
kami 3a4e577b5b refactor(server): decompose DomainEventMapper god-when into per-domain files
DomainEventMapper.kt had grown into a single ~280-line `when` over every
domain event type with a ~40-line flat import list ('god mapper' smell).
Split the when body by domain area into four files, each a suspend fun
returning a MapOutcome sentinel:

- SessionEventMappers.kt   — session/workflow lifecycle + chat turns
- StageInferenceEventMappers.kt — stage transitions + inference lifecycle
- ToolEventMappers.kt      — tool exec/assessment + review findings (+ prettyToolParams)
- LifecycleEventMappers.kt — approval/clarification/proposal/narration/artifact/model/preempt

The dispatcher chains them; MapOutcome.Emit(msg?) vs MapOutcome.Skip
preserves the null-vs-unhandled distinction (a suppressed event like
ArtifactValidating returns Emit(null), not Skip). Public entry points
(DomainEventMapper class, domainEventToServerMessage, prettyToolParams)
unchanged — no behavior change, pure maintainability. Each per-domain
import list is now local to its file. DomainEventMapperTest green.

Vikunja #31.
2026-07-12 18:39:33 +04:00
110 changed files with 6156 additions and 4561 deletions
-1
View File
@@ -1 +0,0 @@
1.txt
-1
View File
@@ -1 +0,0 @@
2.txt
-1
View File
@@ -1 +0,0 @@
3.txt
@@ -96,6 +96,7 @@ data class StatsReportDto(
private const val MS_PER_SECOND = 1000L
private const val SECONDS_PER_MINUTE = 60L
private const val MINUTES_PER_HOUR = 60L
private const val PERCENT_MULTIPLIER = 100.0
private fun humanDuration(ms: Long): String {
if (ms <= 0L) return "0s"
@@ -111,7 +112,7 @@ private fun humanDuration(ms: Long): String {
}
private fun otherPct(report: StatsReportDto): Double =
(100.0 - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0)
(PERCENT_MULTIPLIER - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0)
fun renderStats(report: StatsReportDto): String {
val lines = mutableListOf<String>()
@@ -162,10 +163,10 @@ fun renderStats(report: StatsReportDto): String {
val q = report.quality
lines += "Signal quality"
lines += " extraction: %d fetched, %d low-quality (%.0f%% clean)".format(
Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * 100.0,
Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * PERCENT_MULTIPLIER,
)
lines += " retrieval: %d kept, %d filtered (%.0f%% precision)".format(
Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * 100.0,
Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * PERCENT_MULTIPLIER,
)
lines += " brief drift: ${q.briefDriftCount} capability gaps: ${q.capabilityGapCount}"
lines += ""
@@ -34,6 +34,9 @@ import kotlinx.serialization.json.put
private val taskJson = Json { ignoreUnknownKeys = true }
private const val TASK_ID_COLUMN_WIDTH = 14
private const val TASK_STATUS_COLUMN_WIDTH = 12
/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */
@Serializable
data class TaskRow(
@@ -60,7 +63,9 @@ fun renderTaskList(tasks: List<TaskRow>): String {
if (tasks.isEmpty()) return "no tasks"
return tasks.joinToString("\n") { t ->
val claim = t.claimant?.let { " @$it" }.orEmpty()
"${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd()
val id = t.id.padEnd(TASK_ID_COLUMN_WIDTH)
val status = t.status.padEnd(TASK_STATUS_COLUMN_WIDTH)
"$id $status ${t.title.orEmpty()}$claim".trimEnd()
}
}
@@ -224,8 +229,10 @@ class TaskCreateCommand : TaskHttpCommand("create") {
}
val raw = resp.bodyAsText()
when {
resp.status == HttpStatusCode.Conflict ->
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}")
resp.status == HttpStatusCode.Conflict -> {
val dupes = renderTaskList(taskJson.decodeFromString(raw))
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n$dupes")
}
jsonOut() -> println(raw)
else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
}
@@ -250,7 +257,11 @@ class TaskCompleteCommand : TaskHttpCommand("complete") {
override fun run() = http { client ->
val resp = client.post("${baseUrl()}/tasks/$id/complete")
if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
println("completed $id")
}
}
}
@@ -25,7 +25,9 @@ class TaskRenderTest {
val lines = out.lines()
assertEquals(2, lines.size)
assertTrue(lines[0].startsWith("auth-1"))
assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"))
assertTrue(
lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"),
)
assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE"))
}
@@ -38,7 +40,14 @@ class TaskRenderTest {
fun `renderTaskDetail includes goal, criteria, links and notes`() {
val out = renderTaskDetail(task)
assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh"))
for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) {
val wants = listOf(
"goal: users stay authenticated",
"- rotates",
"backend/auth/**",
"adr-7 (IMPLEMENTS -> DOC)",
"[AGENT] kickoff",
)
for (want in wants) {
assertTrue(out.contains(want), "detail missing: $want")
}
}
@@ -260,6 +260,9 @@ fun main() {
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
)
// Retrieves full tool output the kernel spilled to CAS on truncation (ref shown in-context).
val toolOutputTool = com.correx.infrastructure.tools.ToolOutputTool(artifactStore)
val extraTools = taskTools + toolOutputTool
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
@@ -268,7 +271,7 @@ fun main() {
toolsConfig,
researchToolConfig,
),
extraTools = taskTools,
extraTools = extraTools,
)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
@@ -301,7 +304,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools,
extraTools = extraTools,
)
val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -212,7 +212,12 @@ class ServerModule(
// Live-only: subscribeAll() replays nothing and ServerModule is never built under
// ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8).
NarrationSubscriber(eventStore = eventStore, routerFacade = routerFacade, scope = moduleScope, maxPerRun = narrationMaxPerRun).start()
NarrationSubscriber(
eventStore = eventStore,
routerFacade = routerFacade,
scope = moduleScope,
maxPerRun = narrationMaxPerRun,
).start()
// Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
// probes read the environment and record degraded/restored events; replay reads those facts.
@@ -449,7 +454,10 @@ class ServerModule(
* TOML registry, so the resume route cannot find them there. Rehydrates the artifact
* cache first because the plan content lives in it after a restart.
*/
suspend fun freestyleResumeGraph(sessionId: SessionId, workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? {
suspend fun freestyleResumeGraph(
sessionId: SessionId,
workflowId: String,
): com.correx.core.transitions.graph.WorkflowGraph? {
if (!workflowId.startsWith("freestyle-")) return null
orchestrator.rehydrate(sessionId)
return freestyleDriver?.compiledGraph(sessionId)
@@ -534,6 +542,10 @@ class ServerModule(
val planAlreadyLocked = eventStore.read(sessionId)
.any { it.payload is ExecutionPlanLockedEvent }
if (planAlreadyLocked) return
// completeWorkflow evicts artifactContentCache on the planning graph's completion (#54),
// so the execution_plan slot lockAndRun reads is gone by now. Rebuild it from the durable
// ArtifactContentStoredEvent before handing off. Rehydrate-safe and idempotent.
orchestrator.rehydrate(sessionId)
freestyleDriver?.lockAndRun(sessionId)
}
@@ -614,7 +626,11 @@ class ServerModule(
return@forEach
}
val graph = workflowRegistry.find(orchState.workflowId) ?: run {
log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId)
log.warn(
"resumeAbandoned: no graph for session={} workflowId={}",
sessionId.value,
orchState.workflowId,
)
return@forEach
}
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
@@ -1,55 +1,12 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ReviewFindingDto
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.toDto
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.PreemptRedirectBlockedEvent
import com.correx.core.events.events.PreemptRedirectEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ArtifactId
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger("DomainEventMapper")
internal val log = LoggerFactory.getLogger("DomainEventMapper")
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
suspend fun map(event: StoredEvent, sessionSequence: Long = 0L): ServerMessage? =
@@ -62,379 +19,44 @@ private object NoopArtifactStore : ArtifactStore {
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
@Suppress("CyclomaticComplexMethod")
/**
* Outcome of one per-domain mapper. [Emit] means "this event is mine" — carrying either a
* [ServerMessage] or `null` (handled but deliberately not surfaced, e.g. transient bookkeeping
* events). [Skip] means "not my domain", so the dispatcher tries the next mapper. The distinction
* matters: chaining on a bare `null` would conflate suppression with non-ownership.
*/
internal sealed interface MapOutcome {
@JvmInline
value class Emit(val message: ServerMessage?) : MapOutcome
data object Skip : MapOutcome
}
// Per-domain mappers, tried in order. Each owns a disjoint slice of the payload hierarchy and
// returns [MapOutcome.Skip] for anything outside it. Split across files by domain area to keep
// each mapper and its import list small — see StageInferenceEventMappers, ToolEventMappers, etc.
private val domainMappers: List<suspend (StoredEvent, ArtifactStore, Long) -> MapOutcome> = listOf(
::mapSessionEvent,
::mapStageInferenceEvent,
::mapToolEvent,
::mapLifecycleEvent,
)
suspend fun domainEventToServerMessage(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long = 0L,
): ServerMessage? {
val seq = event.sequence
return when (val p = event.payload) {
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = "chat",
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = p.workflowId,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
sessionId = p.sessionId,
workspaceRoot = p.workspaceRoot,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionNamedEvent -> ServerMessage.SessionRenamed(
sessionId = p.sessionId,
name = p.name,
sequence = seq,
sessionSequence = sessionSequence,
)
is ChatTurnEvent -> ServerMessage.ChatTurn(
sessionId = p.sessionId,
turnId = p.turnId,
role = p.role.name,
content = p.content,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
sessionId = p.sessionId,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowFailedEvent -> ServerMessage.SessionFailed(
sessionId = p.sessionId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TransitionExecutedEvent -> ServerMessage.StageStarted(
sessionId = p.sessionId,
stageId = p.to,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageCompletedEvent -> ServerMessage.StageCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageFailedEvent -> ServerMessage.StageFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is OrchestrationPausedEvent -> mapOrchestrationPaused(p, seq, sessionSequence)
is OrchestrationResumedEvent -> ServerMessage.SessionResumed(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceStartedEvent -> ServerMessage.InferenceStarted(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore, sessionSequence)
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
sessionId = p.sessionId,
stageId = p.stageId,
elapsedMs = p.timeoutMs,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceFailedEvent -> ServerMessage.InferenceFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is RetryAttemptedEvent -> ServerMessage.RetryAttempted(
sessionId = p.sessionId,
stageId = p.stageId,
attemptNumber = p.attemptNumber,
maxAttempts = p.maxAttempts,
failureReason = p.failureReason,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
sessionId = p.sessionId,
toolName = p.toolName,
tier = p.tier,
params = prettyToolParams(p.request.parameters),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
sessionId = p.sessionId,
toolName = p.toolName,
outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
diff = p.receipt.diff,
affectedEntities = p.receipt.affectedEntities,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
sessionId = p.sessionId,
stageId = p.stageId,
toolName = p.toolName,
disposition = p.disposition.name,
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
sessionId = p.sessionId,
stageId = p.stageId,
verdict = p.verdict.name,
findings = p.findings.map {
ReviewFindingDto(
severity = it.severity.name,
confidence = it.confidence,
category = it.category,
target = it.target,
message = it.message,
suggestedFix = it.suggestedFix,
correctness = it.correctness,
)
},
blocked = p.blocked,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
sessionId = p.sessionId,
requestId = p.requestId,
stageId = p.stageId,
questions = p.questions,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
sessionId = p.sessionId,
proposalId = p.proposalId,
prompt = p.prompt,
candidates = p.candidates,
originalRequest = p.originalRequest,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
sessionId = event.metadata.sessionId,
requestId = p.requestId,
outcome = p.outcome.name,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TalkieNarrationEvent -> ServerMessage.Narration(
sessionId = p.sessionId,
content = p.content,
stageId = p.stageId,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
// Transient pre-validation marker, emitted microseconds before Validated or a
// stage failure — either of those carries the outcome the operator cares about.
is ArtifactValidatingEvent -> null
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
sessionId = p.sessionId,
workflowId = p.workflowId,
stageIds = p.stageIds,
sequence = seq,
sessionSequence = sessionSequence,
)
is ModelLoadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = true,
)
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = false,
)
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
is ArtifactContentStoredEvent -> null
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
// operator surface ships with the LLM-proposal + approval-confirm front-half.
is PreemptRedirectEvent -> null
is PreemptRedirectBlockedEvent -> null
else -> {
for (mapper in domainMappers) {
when (val outcome = mapper(event, artifactStore, sessionSequence)) {
is MapOutcome.Emit -> return outcome.message
MapOutcome.Skip -> Unit
}
}
log.debug(
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
p::class.simpleName,
event.payload::class.simpleName,
event.metadata.sessionId,
event.sequence,
)
null
}
}
return null
}
private fun mapOrchestrationPaused(
p: OrchestrationPausedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage {
val reason = when (p.reason) {
"APPROVAL_PENDING" -> PauseReason.APPROVAL_PENDING
"CLARIFICATION_PENDING" -> PauseReason.CLARIFICATION_PENDING
"ABANDONED_STALE" -> PauseReason.ABANDONED_STALE
else -> PauseReason.USER_REQUESTED
}
return ServerMessage.SessionPaused(
sessionId = p.sessionId,
reason = reason,
sequence = seq,
sessionSequence = sessionSequence,
)
}
private suspend fun mapInferenceCompleted(
event: StoredEvent,
p: InferenceCompletedEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): ServerMessage {
val response = runCatching {
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
}.getOrElse { "" }
val reasoning = p.reasoningArtifactId?.let { id ->
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
} ?: ""
return ServerMessage.InferenceCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
outputSummary = response,
responseText = response,
reasoning = reasoning,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
totalTokens = p.tokensUsed.totalTokens,
sequence = event.sequence,
sessionSequence = sessionSequence,
)
}
private fun mapApprovalRequested(
p: ApprovalRequestedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage =
ServerMessage.ApprovalRequired(
sessionId = p.sessionId,
requestId = p.requestId,
tier = p.tier,
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
level = p.tier.name,
factors = emptyList(),
recommendedAction = RiskAction.PROMPT_USER.name,
rationale = emptyList(),
),
toolName = p.toolName,
preview = p.preview,
sequence = seq,
sessionSequence = sessionSequence,
)
/**
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
*/
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
if (parameters.isEmpty()) return emptyList()
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
val ordered = parameters.entries.sortedWith(
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
)
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
}
private fun formatParamValue(value: Any?): String {
val raw = when (value) {
null -> "null"
is String -> value
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
else -> value.toString()
}
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "" else flattened
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
}
private const val MAX_PARAM_CELLS = 5
private const val MAX_PARAM_VALUE_LEN = 80
@@ -0,0 +1,150 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.toDto
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
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.TalkieNarrationEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.risk.RiskAction
/**
* Approval / clarification / workflow-proposal / narration / artifact / model / preempt events —
* the remaining operator-facing (or deliberately suppressed) lifecycle surface. Branches that
* return `null` are handled-but-not-surfaced (transient bookkeeping); wrapping them in
* [MapOutcome.Emit] keeps them from falling through to the dispatcher's "unmapped" log.
*/
@Suppress("UnusedParameter", "LongMethod", "CyclomaticComplexMethod")
internal suspend fun mapLifecycleEvent(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): MapOutcome {
val seq = event.sequence
val msg: ServerMessage? = when (val p = event.payload) {
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
sessionId = p.sessionId,
requestId = p.requestId,
stageId = p.stageId,
questions = p.questions,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
sessionId = p.sessionId,
proposalId = p.proposalId,
prompt = p.prompt,
candidates = p.candidates,
originalRequest = p.originalRequest,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
sessionId = event.metadata.sessionId,
requestId = p.requestId,
outcome = p.outcome.name,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is TalkieNarrationEvent -> ServerMessage.Narration(
sessionId = p.sessionId,
content = p.content,
stageId = p.stageId,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
is ArtifactValidatedEvent -> ServerMessage.ArtifactValidated(
sessionId = p.sessionId,
stageId = p.stageId,
artifactId = p.artifactId,
sequence = seq,
sessionSequence = sessionSequence,
)
// Transient pre-validation marker, emitted microseconds before Validated or a
// stage failure — either of those carries the outcome the operator cares about.
is ArtifactValidatingEvent -> null
is ExecutionPlanLockedEvent -> ServerMessage.PlanLocked(
sessionId = p.sessionId,
workflowId = p.workflowId,
stageIds = p.stageIds,
sequence = seq,
sessionSequence = sessionSequence,
)
is ModelLoadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = true,
)
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = false,
)
// Internal slot→CAS-hash bookkeeping (F-007 durable bridge); no operator-facing surface.
is ArtifactContentStoredEvent -> null
// Freestyle graph-rerouting bookkeeping. The deterministic record is in place; a dedicated
// operator surface ships with the LLM-proposal + approval-confirm front-half.
is PreemptRedirectEvent -> null
is PreemptRedirectBlockedEvent -> null
else -> return MapOutcome.Skip
}
return MapOutcome.Emit(msg)
}
private fun mapApprovalRequested(
p: ApprovalRequestedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage =
ServerMessage.ApprovalRequired(
sessionId = p.sessionId,
requestId = p.requestId,
tier = p.tier,
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
level = p.tier.name,
factors = emptyList(),
recommendedAction = RiskAction.PROMPT_USER.name,
rationale = emptyList(),
),
toolName = p.toolName,
preview = p.preview,
sequence = seq,
sessionSequence = sessionSequence,
)
@@ -0,0 +1,78 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
/** Session lifecycle + chat-turn events. [artifactStore] is unused here but kept for a uniform mapper signature. */
@Suppress("UnusedParameter", "LongMethod")
internal suspend fun mapSessionEvent(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): MapOutcome {
val seq = event.sequence
val msg = when (val p = event.payload) {
is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = "chat",
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowStartedEvent -> ServerMessage.SessionAnnounced(
sessionId = p.sessionId,
workflowId = p.workflowId,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
sessionId = p.sessionId,
workspaceRoot = p.workspaceRoot,
sequence = seq,
sessionSequence = sessionSequence,
)
is SessionNamedEvent -> ServerMessage.SessionRenamed(
sessionId = p.sessionId,
name = p.name,
sequence = seq,
sessionSequence = sessionSequence,
)
is ChatTurnEvent -> ServerMessage.ChatTurn(
sessionId = p.sessionId,
turnId = p.turnId,
role = p.role.name,
content = p.content,
latencyMs = p.latencyMs,
totalTokens = p.tokensUsed?.totalTokens,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(
sessionId = p.sessionId,
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowFailedEvent -> ServerMessage.SessionFailed(
sessionId = p.sessionId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
else -> return MapOutcome.Skip
}
return MapOutcome.Emit(msg)
}
@@ -0,0 +1,141 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
/** Stage transition + inference lifecycle events (some carry timestamps / need the artifact store). */
@Suppress("LongMethod")
internal suspend fun mapStageInferenceEvent(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): MapOutcome {
val seq = event.sequence
val msg = when (val p = event.payload) {
is TransitionExecutedEvent -> ServerMessage.StageStarted(
sessionId = p.sessionId,
stageId = p.to,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageCompletedEvent -> ServerMessage.StageCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is StageFailedEvent -> ServerMessage.StageFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is OrchestrationPausedEvent -> mapOrchestrationPaused(p, seq, sessionSequence)
is OrchestrationResumedEvent -> ServerMessage.SessionResumed(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceStartedEvent -> ServerMessage.InferenceStarted(
sessionId = p.sessionId,
stageId = p.stageId,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore, sessionSequence)
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
sessionId = p.sessionId,
stageId = p.stageId,
elapsedMs = p.timeoutMs,
sequence = seq,
sessionSequence = sessionSequence,
)
is InferenceFailedEvent -> ServerMessage.InferenceFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is RetryAttemptedEvent -> ServerMessage.RetryAttempted(
sessionId = p.sessionId,
stageId = p.stageId,
attemptNumber = p.attemptNumber,
maxAttempts = p.maxAttempts,
failureReason = p.failureReason,
sequence = seq,
sessionSequence = sessionSequence,
)
else -> return MapOutcome.Skip
}
return MapOutcome.Emit(msg)
}
private fun mapOrchestrationPaused(
p: OrchestrationPausedEvent,
seq: Long,
sessionSequence: Long,
): ServerMessage {
val reason = when (p.reason) {
"APPROVAL_PENDING" -> PauseReason.APPROVAL_PENDING
"CLARIFICATION_PENDING" -> PauseReason.CLARIFICATION_PENDING
"ABANDONED_STALE" -> PauseReason.ABANDONED_STALE
else -> PauseReason.USER_REQUESTED
}
return ServerMessage.SessionPaused(
sessionId = p.sessionId,
reason = reason,
sequence = seq,
sessionSequence = sessionSequence,
)
}
private suspend fun mapInferenceCompleted(
event: StoredEvent,
p: InferenceCompletedEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): ServerMessage {
val response = runCatching {
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
}.getOrElse { "" }
val reasoning = p.reasoningArtifactId?.let { id ->
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
} ?: ""
return ServerMessage.InferenceCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
outputSummary = response,
responseText = response,
reasoning = reasoning,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
totalTokens = p.tokensUsed.totalTokens,
sequence = event.sequence,
sessionSequence = sessionSequence,
)
}
@@ -0,0 +1,125 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.ReviewFindingDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
/** Tool invocation / execution / assessment + review-findings events. */
@Suppress("UnusedParameter", "LongMethod")
internal suspend fun mapToolEvent(
event: StoredEvent,
artifactStore: ArtifactStore,
sessionSequence: Long,
): MapOutcome {
val seq = event.sequence
val msg = when (val p = event.payload) {
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
sessionId = p.sessionId,
toolName = p.toolName,
tier = p.tier,
params = prettyToolParams(p.request.parameters),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
sessionId = p.sessionId,
toolName = p.toolName,
outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
diff = p.receipt.diff,
affectedEntities = p.receipt.affectedEntities,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
sequence = seq,
sessionSequence = sessionSequence,
)
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
sessionId = p.sessionId,
stageId = p.stageId,
toolName = p.toolName,
disposition = p.disposition.name,
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
sessionId = p.sessionId,
stageId = p.stageId,
verdict = p.verdict.name,
findings = p.findings.map {
ReviewFindingDto(
severity = it.severity.name,
confidence = it.confidence,
category = it.category,
target = it.target,
message = it.message,
suggestedFix = it.suggestedFix,
correctness = it.correctness,
)
},
blocked = p.blocked,
sequence = seq,
sessionSequence = sessionSequence,
)
else -> return MapOutcome.Skip
}
return MapOutcome.Emit(msg)
}
/**
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
*/
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
if (parameters.isEmpty()) return emptyList()
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
val ordered = parameters.entries.sortedWith(
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
)
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
}
private fun formatParamValue(value: Any?): String {
val raw = when (value) {
null -> "null"
is String -> value
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
else -> value.toString()
}
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "" else flattened
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
}
private const val MAX_PARAM_CELLS = 5
private const val MAX_PARAM_VALUE_LEN = 80
@@ -60,7 +60,8 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents()
override fun allSessionIds(): Set<SessionId> = delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
override fun allSessionIds(): Set<SessionId> =
delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
override fun subscribeAll(): Flow<StoredEvent> = delegate.subscribeAll()
@@ -143,14 +143,28 @@ class RepoMapIndexer(
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
"kt" to Regex("""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"java" to Regex("""(?m)^\s*(?:public |private |protected |final |abstract |static )*(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"kt" to Regex(
"""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*""" +
"""(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
"java" to Regex(
"""(?m)^\s*(?:public |private |protected |final |abstract |static )*""" +
"""(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
"py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"js" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
"js" to Regex(
"""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+""" +
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
),
"ts" to Regex(
"""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+""" +
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
),
// GDScript: column-0 declarations only, so indented inner-class members never match.
"gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"gd" to Regex(
"""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
// C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative
// (no method capture — return-type heuristics there are noisy and risk false positives).
"cs" to Regex(
@@ -116,11 +116,13 @@ class NarrationSubscriber(
closeLane(sid)
}
is WorkflowFailedEvent -> {
val instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. " +
"Explain the failure to the user."
enqueue(
sid,
NarrationTrigger(
kind = "workflow_failed",
instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. Explain the failure to the user.",
instruction = instruction,
stageId = p.stageId.value,
),
)
@@ -139,21 +141,27 @@ class NarrationSubscriber(
),
)
}
is ExecutionPlanRejectedEvent -> enqueue(
is ExecutionPlanRejectedEvent -> {
val instruction = "The execution plan was rejected (${p.source}): ${p.reason}. " +
"Explain to the user what happened and what they can do next."
enqueue(
sid,
NarrationTrigger(
kind = "plan_rejected",
instruction = "The execution plan was rejected (${p.source}): ${p.reason}. Explain to the user what happened and what they can do next.",
instruction = instruction,
),
)
}
is OrchestrationPausedEvent -> {
// Approval pauses are narrated from ApprovalRequestedEvent above.
if (!p.reason.contains("APPROVAL", ignoreCase = true)) {
val instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. " +
"Inform the user."
enqueue(
sid,
NarrationTrigger(
kind = "paused",
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
instruction = instruction,
stageId = p.stageId.value,
),
pauseKey = SESSION_PAUSE_KEY,
@@ -63,20 +63,49 @@ class ReplayInspectionService(private val eventStore: EventStore) {
private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry =
when (payload) {
is WorkflowStartedEvent -> TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
is StageCompletedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}")
is StageFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value}${payload.reason}")
is WorkflowStartedEvent ->
TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
is StageCompletedEvent ->
TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}")
is StageFailedEvent -> TimelineEntry(
sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value}${payload.reason}",
)
is ToolExecutionCompletedEvent -> TimelineEntry(sequence, type, null, "Tool executed: ${payload.toolName}")
is ToolExecutionFailedEvent -> TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName}${payload.reason}")
is ToolExecutionRejectedEvent -> TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName}${payload.reason}")
is ApprovalRequestedEvent -> TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
is ApprovalDecisionResolvedEvent -> TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
is OrchestrationPausedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration paused at ${payload.stageId.value}: ${payload.reason}")
is OrchestrationResumedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}")
is RetryAttemptedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at ${payload.stageId.value}: ${payload.failureReason}")
is TransitionExecutedEvent -> TimelineEntry(sequence, type, payload.from.value, "Transition ${payload.from.value} ${payload.to.value}")
is WorkflowCompletedEvent -> TimelineEntry(sequence, type, payload.terminalStageId.value, "Workflow completed after ${payload.totalStages} stages")
is WorkflowFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}")
is ToolExecutionFailedEvent ->
TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName}${payload.reason}")
is ToolExecutionRejectedEvent ->
TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName}${payload.reason}")
is ApprovalRequestedEvent ->
TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
is ApprovalDecisionResolvedEvent ->
TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
is OrchestrationPausedEvent -> TimelineEntry(
sequence,
type,
payload.stageId.value,
"Orchestration paused at ${payload.stageId.value}: ${payload.reason}",
)
is OrchestrationResumedEvent -> TimelineEntry(
sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}",
)
is RetryAttemptedEvent -> TimelineEntry(
sequence,
type,
payload.stageId.value,
"Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at " +
"${payload.stageId.value}: ${payload.failureReason}",
)
is TransitionExecutedEvent -> TimelineEntry(
sequence, type, payload.from.value, "Transition ${payload.from.value}${payload.to.value}",
)
is WorkflowCompletedEvent -> TimelineEntry(
sequence,
type,
payload.terminalStageId.value,
"Workflow completed after ${payload.totalStages} stages",
)
is WorkflowFailedEvent ->
TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}")
else -> TimelineEntry(sequence, type, null, type)
}
@@ -566,7 +566,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
suspend fun requireToolName(scopeLabel: String): String? =
msg.toolName?.takeIf { it.isNotBlank() }
?: run {
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
val errMsg = "CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"
sendFrame(errorResponse(errMsg))
null
}
@@ -683,7 +684,9 @@ class GlobalStreamHandler(private val module: ServerModule) {
// Bind the per-repo project profile so router triage can cite conventions/commands.
// Parity with launchSessionRun — chat sessions previously never bound a profile.
runCatching { module.bindProjectProfile(sessionId) }
.onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) }
.onFailure {
log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message)
}
withSessionContext(sessionId) {
runCatching {
@@ -102,7 +102,12 @@ class SessionStreamHandler(private val module: ServerModule) {
),
)
}.onFailure {
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
log.error(
"routerFacade.onUserInput failed for session={}: {}",
msg.sessionId.value,
it.message,
it,
)
val error = ServerMessage.ProtocolError("Router error: ${it.message}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
}
@@ -325,7 +325,9 @@ class DomainEventMapperTest {
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L),
ServerMessage.ToolStarted(
sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L,
),
result,
)
}
@@ -436,7 +438,8 @@ class DomainEventMapperTest {
projectId = null,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
val result =
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
assertEquals(requestId, result.requestId)
assertEquals(Tier.T3, result.tier)
assertNull(result.toolName)
@@ -470,11 +473,15 @@ class DomainEventMapperTest {
projectId = null,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
val result =
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
assertEquals(requestId, result.requestId)
assertEquals("HIGH", result.riskSummary.level)
assertEquals("PROMPT_USER", result.riskSummary.recommendedAction)
assertEquals(listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), result.riskSummary.rationale)
assertEquals(
listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"),
result.riskSummary.rationale,
)
assertEquals(2, result.riskSummary.factors.size)
assertTrue(result.riskSummary.factors[0].contains("Validation errors"))
assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
@@ -495,7 +502,8 @@ class DomainEventMapperTest {
userSteering = null,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved
val result =
domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved
assertEquals(sessionId, result.sessionId)
assertEquals(requestId, result.requestId)
assertEquals("APPROVED", result.outcome)
@@ -519,7 +527,8 @@ class DomainEventMapperTest {
userSteering = null,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved
val result =
domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved
assertEquals("REJECTED", result.outcome)
assertNull(result.reason)
}
@@ -325,8 +325,14 @@ class SessionEventBridgeTest {
val snapshot = sent[0] as ServerMessage.SessionSnapshot
// WorkflowStartedEvent is filtered out by eventToEntry (maps to null)
assertEquals(3, snapshot.recentEvents.size)
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"), snapshot.recentEvents[0])
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value), snapshot.recentEvents[1])
assertEquals(
EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"),
snapshot.recentEvents[0],
)
assertEquals(
EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value),
snapshot.recentEvents[1],
)
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "SessionCompleted", ""), snapshot.recentEvents[2])
}
@@ -359,7 +365,13 @@ class SessionEventBridgeTest {
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
val store = fakeEventStore(allEventsList = emptyList())
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(1, sent.size)
assertEquals(ServerMessage.SnapshotComplete, sent[0])
@@ -370,7 +382,13 @@ class SessionEventBridgeTest {
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(ServerMessage.SnapshotComplete, sent.last())
}
@@ -385,7 +403,13 @@ class SessionEventBridgeTest {
override suspend fun lastGlobalSequence(): Long = 5L
}
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(5L, snapshot.lastSequence)
@@ -399,7 +423,13 @@ class SessionEventBridgeTest {
)
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(3L, snapshot.lastSessionSequence)
@@ -257,7 +257,10 @@ class FreestyleDriverTest {
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}")
assertTrue(
lint.hardFailures.any { it.code == "unproduced_need" },
"expected unproduced_need: ${lint.hardFailures}",
)
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
assertEquals("lint", rejected.source)
@@ -175,7 +175,8 @@ class ModelLifecycleLiveTest {
val raw = frame.readText()
when (val msg = decodeServerMessage(raw)) {
is ServerMessage.ModelChanged -> modelChanged = msg
is ServerMessage.ProtocolError -> error("Unexpected protocol error during swap: ${msg.message}")
is ServerMessage.ProtocolError ->
error("Unexpected protocol error during swap: ${msg.message}")
else -> Unit
}
}
@@ -29,7 +29,11 @@ private class OnesEmbedder(override val dimension: Int = 8) : Embedder {
class ProjectMemoryServiceTest {
private fun store(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload, es: InMemoryEventStore) =
private fun store(
sessionId: SessionId,
payload: com.correx.core.events.events.EventPayload,
es: InMemoryEventStore,
) =
runBlocking {
es.append(
NewEvent(
@@ -58,7 +62,11 @@ class ProjectMemoryServiceTest {
val sessionA = SessionId("A")
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore)
store(sessionA, TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")), eventStore)
store(
sessionA,
TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")),
eventStore,
)
service(config, eventStore, l3).persist(sessionA, "/repo")
@@ -67,7 +75,10 @@ class ProjectMemoryServiceTest {
assertTrue(seeded.any { it.contains("use jwt for auth") }, "expected prior decision retrieved")
val note = eventStore.read(sessionB).mapNotNull { it.payload as? SteeringNoteAddedEvent }.firstOrNull()
assertTrue(note != null && note.content.contains("Project memory"), "expected seeded steering note in session B")
assertTrue(
note != null && note.content.contains("Project memory"),
"expected seeded steering note in session B",
)
}
@Test
@@ -109,7 +109,9 @@ class NarrationSubscriberTest {
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
liveFlow.emit(storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L))
liveFlow.emit(
storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L),
)
liveFlow.emit(storedEvent(WorkflowCompletedEvent(sessionId, StageId("c"), totalStages = 2), seq = 4L))
withTimeout(2_000L) {
@@ -282,7 +282,9 @@ class ServerMessageSerializationTest {
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" }
assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" }
assert(jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\"")) { "expected original request" }
assert(
jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\""),
) { "expected original request" }
val decoded = json.decodeFromString<ServerMessage.WorkflowProposed>(jsonStr)
assertEquals(2, decoded.candidates.size)
@@ -56,8 +56,10 @@ class FileSystemWorkflowRegistryTest {
@Test
fun `listAll falls back to workflowId when description absent`() {
dir.resolve("healthcheck.toml").writeText(validToml.replace("description = \"Runs the health check pipeline\"\n", ""))
assertEquals("healthcheck", FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single().description)
val noDescToml = validToml.replace("description = \"Runs the health check pipeline\"\n", "")
dir.resolve("healthcheck.toml").writeText(noDescToml)
val registry = FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir)
assertEquals("healthcheck", registry.listAll().single().description)
}
@Test
@@ -87,10 +87,12 @@ class ReplayInspectionServiceTest {
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun lastSequence(sessionId: SessionId): Long? =
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L
@@ -86,18 +86,26 @@ class EventInspectRoutesTest {
)
private val seedEvents = listOf(
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), 1L),
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), 2L),
storedEvent(
WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"),
1L,
),
storedEvent(
WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"),
2L,
),
storedEvent(InitialIntentEvent(sessionId = s1, intent = "build the thing"), 3L),
)
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun lastSequence(sessionId: SessionId): Long? =
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L
@@ -180,7 +180,11 @@ class SessionUndoServiceTest {
val noWorkspaceEventStore = FakeEventStore(listOf(storedEvent(fileWritten, sessionId, 1L)))
val serviceBootOnly = SessionUndoService(noWorkspaceEventStore, storeNoWorkspace, setOf(bootDir))
val summaryBootOnly = serviceBootOnly.undo(sessionId)
assertEquals(1, summaryBootOnly.rejected, "boot-only roots must jail the workspace-dir file when no bound event exists")
assertEquals(
1,
summaryBootOnly.rejected,
"boot-only roots must jail the workspace-dir file when no bound event exists",
)
}
/**
@@ -121,7 +121,13 @@ class GlobalStreamHandlerTest {
orchRepo: OrchestrationRepository,
): Pair<SessionEventBridge, Channel<ServerMessage>> {
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, noopWorkflowRegistry, noopToolRegistry) { received.send(it) }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
orchRepo,
noopWorkflowRegistry,
noopToolRegistry,
) { received.send(it) }
return bridge to received
}
@@ -167,7 +173,13 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 64)
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) {
val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) {
received.send(it)
}
val mapper = DomainEventMapper(noopArtifactStore)
@@ -209,7 +221,13 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 16)
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) {
val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) {
received.send(it)
}
val mapper = DomainEventMapper(noopArtifactStore)
@@ -257,7 +275,13 @@ class GlobalStreamHandlerTest {
return 0L
}
}
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { }
val mapper = DomainEventMapper(noopArtifactStore)
val job = launch {
@@ -274,7 +298,13 @@ class GlobalStreamHandlerTest {
fun `shutdown - cancel outer scope ends subscription with no leaks`() = runTest {
val liveFlow = MutableSharedFlow<StoredEvent>()
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { }
val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { }
val mapper = DomainEventMapper(noopArtifactStore)
val job = launch {
@@ -250,7 +250,8 @@ class WorkspaceHandshakeTest {
delay(50)
// Phase 2: start a session
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
send(Frame.Text(encode(startMsg)))
// Give the server time to process the event emission (before orchestrator runs)
delay(200)
@@ -298,7 +299,8 @@ class WorkspaceHandshakeTest {
delay(50)
// StartSession — this closes the Hello window
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
send(Frame.Text(encode(startMsg)))
delay(100)
// Late Hello with a different path — must be ignored
@@ -334,7 +336,8 @@ class WorkspaceHandshakeTest {
delay(100)
// No Hello — send StartSession directly
send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null))))
val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)
send(Frame.Text(encode(startMsg)))
delay(200)
}
}
+8
View File
@@ -41,6 +41,14 @@ subprojects {
apply plugin: "io.gitlab.arturbosch.detekt"
apply plugin: "org.jetbrains.kotlinx.kover"
// Unique jar base names from the full project path (core:inference -> core-inference).
// Sibling modules share leaf names (core:inference vs infrastructure:inference, core:tools
// vs infrastructure:tools), which collide in the flat application-plugin lib dir and drop
// classes at runtime. Derive per-path names so every jar lands distinctly.
tasks.withType(Jar).configureEach {
archiveBaseName = project.path.substring(1).replace(':', '-')
}
detekt {
toolVersion = "1.23.7"
config.setFrom("$rootDir/detekt.yml")
@@ -19,7 +19,11 @@ package com.correx.core.artifacts.kind
object KindContractTable {
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map<String, String> = emptyMap())
private data class Template(
val id: String,
val evaluator: AssertionEvaluator,
val args: Map<String, String> = emptyMap(),
)
private val FLOOR = listOf(
Template("file_exists", AssertionEvaluator.FS),
@@ -104,7 +104,10 @@ internal object SimpleToml {
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) }
ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() }
ch == ',' && !inQuotes -> {
result.add(stripQuotes(current.toString().trim()))
current = StringBuilder()
}
else -> current.append(ch)
}
}
@@ -181,6 +184,46 @@ object ProfileLoader {
}
object ConfigLoader {
private const val DEFAULT_SERVER_PORT = 8080
private const val DEFAULT_SESSION_LIST_LIMIT = 5
private const val DEFAULT_EMBEDDER_DIMENSION = 1536
private const val DEFAULT_L3_DIM = 1536
private const val DEFAULT_L3_BIT_WIDTH = 4
private const val DEFAULT_GENERATION_TEMPERATURE = 0.7
private const val DEFAULT_GENERATION_TOP_P = 0.9
private const val DEFAULT_GENERATION_MAX_TOKENS = 512
private const val DEFAULT_NARRATION_TEMPERATURE = 0.7
private const val DEFAULT_NARRATION_TOP_P = 0.9
private const val DEFAULT_NARRATION_MAX_TOKENS = 4096
private const val DEFAULT_NARRATION_MAX_PER_RUN = 100
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
private const val DEFAULT_RETRIEVAL_K = 5
private const val DEFAULT_TOKEN_BUDGET = 4096
private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192
private const val DEFAULT_PROJECT_MEMORY_K = 5
private const val DEFAULT_PROJECT_MAX_DEPTH = 4
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
private const val DEFAULT_STAGE_TIMEOUT_MS = 180_000L
private const val DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD = 2_000
private const val DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES = 1_440L
private const val DEFAULT_COMPRESSION_LEVEL = 4
private const val DEFAULT_MAX_TOOL_ROUNDS = 30
private const val DEFAULT_READ_LOOP_NUDGE_THRESHOLD = 3
private const val DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD = 3
private const val DEFAULT_MAX_FEEDBACK_ISSUES = 3
private const val DEFAULT_REPO_MAP_INJECT_TOP_K = 30
private const val DEFAULT_REPO_MAP_FILES_PER_DIR = 8
private const val DEFAULT_DOCS_CATALOG_MAX = 20
private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3
private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20
private const val DEFAULT_MAX_REFINEMENT = 3
private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2
private const val DEFAULT_INTENT_ROUTE_BUDGET = 2
private const val DEFAULT_MODELS_PORT = 10000
private const val DEFAULT_SAMPLING_TOP_P = 1.0
private const val DEFAULT_SAMPLING_TEMPERATURE = 0.7
fun load(): CorrexConfig {
val path = configPath()
if (!Files.exists(path)) {
@@ -302,7 +345,7 @@ object ConfigLoader {
}
// JSON-style array: ["item1", "item2"]
valueStr.startsWith("[") && valueStr.endsWith("]") -> {
parseArray(valueStr, lineNum)
parseArray(valueStr)
}
// CSV fallback for backward compat (detect by "," and "=" pattern without brackets)
valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> {
@@ -336,7 +379,7 @@ object ConfigLoader {
return result
}
private fun parseArray(arrayStr: String, lineNum: Int): List<String> {
private fun parseArray(arrayStr: String): List<String> {
val result = mutableListOf<String>()
val content = arrayStr.substring(1, arrayStr.length - 1).trim()
if (content.isEmpty()) return result
@@ -430,12 +473,12 @@ object ConfigLoader {
val server = ServerConfig(
host = asString(serverSection["host"], "localhost"),
port = asInt(serverSection["port"], 8080),
port = asInt(serverSection["port"], DEFAULT_SERVER_PORT),
)
val tui = TuiConfig(
theme = asString(tuiSection["theme"], "dark"),
sessionListLimit = asInt(tuiSection["session_list_limit"], 5),
sessionListLimit = asInt(tuiSection["session_list_limit"], DEFAULT_SESSION_LIST_LIMIT),
)
val cli = CliConfig(
@@ -447,7 +490,9 @@ object ConfigLoader {
val shellEnabled = when {
toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true)
toolsSection.containsKey("shell_enabled") -> {
System.err.println("Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead")
System.err.println(
"Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead"
)
asBoolean(toolsSection["shell_enabled"], true)
}
else -> true
@@ -456,7 +501,9 @@ object ConfigLoader {
val fileReadEnabled = when {
toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true)
toolsSection.containsKey("file_read_enabled") -> {
System.err.println("Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead")
System.err.println(
"Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead"
)
asBoolean(toolsSection["file_read_enabled"], true)
}
else -> true
@@ -465,7 +512,9 @@ object ConfigLoader {
val fileWriteEnabled = when {
toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true)
toolsSection.containsKey("file_write_enabled") -> {
System.err.println("Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead")
System.err.println(
"Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead"
)
asBoolean(toolsSection["file_write_enabled"], true)
}
else -> true
@@ -474,7 +523,9 @@ object ConfigLoader {
val fileEditEnabled = when {
toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true)
toolsSection.containsKey("file_edit_enabled") -> {
System.err.println("Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead")
System.err.println(
"Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead"
)
asBoolean(toolsSection["file_edit_enabled"], true)
}
else -> true
@@ -491,7 +542,9 @@ object ConfigLoader {
val1 is String -> {
// Backward compat: check if it's CSV or already a single item
if (val1.contains(",")) {
System.err.println("Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead")
System.err.println(
"Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead"
)
val1.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else {
listOf(val1)
@@ -547,7 +600,7 @@ object ConfigLoader {
val embedder = EmbedderConfig(
backend = asString(routerEmbedderSection["backend"], "noop"),
dimension = asInt(routerEmbedderSection["dimension"], 1536),
dimension = asInt(routerEmbedderSection["dimension"], DEFAULT_EMBEDDER_DIMENSION),
url = asStringOrNull(routerEmbedderSection["url"]),
modelId = asStringOrNull(routerEmbedderSection["model_id"]),
)
@@ -557,30 +610,30 @@ object ConfigLoader {
persistPath = asStringOrNull(routerL3Section["persist_path"]),
pythonExecutable = asString(routerL3Section["python_executable"], "python3"),
scriptPath = asStringOrNull(routerL3Section["script_path"]),
dim = asInt(routerL3Section["dim"], 1536),
bitWidth = asInt(routerL3Section["bit_width"], 4),
dim = asInt(routerL3Section["dim"], DEFAULT_L3_DIM),
bitWidth = asInt(routerL3Section["bit_width"], DEFAULT_L3_BIT_WIDTH),
)
val generation = GenerationSettings(
temperature = asDouble(routerGenerationSection["temperature"], 0.7),
topP = asDouble(routerGenerationSection["top_p"], 0.9),
maxTokens = asInt(routerGenerationSection["max_tokens"], 512),
temperature = asDouble(routerGenerationSection["temperature"], DEFAULT_GENERATION_TEMPERATURE),
topP = asDouble(routerGenerationSection["top_p"], DEFAULT_GENERATION_TOP_P),
maxTokens = asInt(routerGenerationSection["max_tokens"], DEFAULT_GENERATION_MAX_TOKENS),
)
val narration = NarrationSettings(
temperature = asDouble(routerNarrationSection["temperature"], 0.7),
topP = asDouble(routerNarrationSection["top_p"], 0.9),
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096),
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100),
temperature = asDouble(routerNarrationSection["temperature"], DEFAULT_NARRATION_TEMPERATURE),
topP = asDouble(routerNarrationSection["top_p"], DEFAULT_NARRATION_TOP_P),
maxTokens = asInt(routerNarrationSection["max_tokens"], DEFAULT_NARRATION_MAX_TOKENS),
maxPerRun = asInt(routerNarrationSection["max_per_run"], DEFAULT_NARRATION_MAX_PER_RUN),
modelId = asStringOrNull(routerNarrationSection["model_id"]),
)
val router = TalkieConfig(
embedder = embedder,
l3 = l3,
conversationKeepLast = asInt(routerSection["conversation_keep_last"], 6),
retrievalK = asInt(routerSection["retrieval_k"], 5),
tokenBudget = asInt(routerSection["token_budget"], 4096),
conversationKeepLast = asInt(routerSection["conversation_keep_last"], DEFAULT_CONVERSATION_KEEP_LAST),
retrievalK = asInt(routerSection["retrieval_k"], DEFAULT_RETRIEVAL_K),
tokenBudget = asInt(routerSection["token_budget"], DEFAULT_TOKEN_BUDGET),
generation = generation,
narration = narration,
)
@@ -588,7 +641,7 @@ object ConfigLoader {
val models = modelsList.mapNotNull { modelMap ->
val id = asString(modelMap["id"]) ?: return@mapNotNull null
val modelPath = asString(modelMap["model_path"]) ?: return@mapNotNull null
val contextSize = asInt(modelMap["context_size"], 8192)
val contextSize = asInt(modelMap["context_size"], DEFAULT_MODEL_CONTEXT_SIZE)
@Suppress("UNCHECKED_CAST")
val params = (modelMap["params"] as? Map<String, Any>)
?.mapValues { it.value.toString() }
@@ -614,42 +667,53 @@ object ConfigLoader {
val project = ProjectConfig(
enabled = asBoolean(projectSection["enabled"], false),
root = asString(projectSection["root"], ""),
memoryK = asInt(projectSection["memory_k"], 5),
maxDepth = asInt(projectSection["max_depth"], 4),
memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
injectTopK = asInt(projectSection["inject_top_k"], 30),
injectTopK = asInt(projectSection["inject_top_k"], DEFAULT_PROJECT_INJECT_TOP_K),
)
val orchestrationSection = sections["orchestration"] ?: emptyMap()
val orchestration = OrchestrationKnobs(
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000),
journalCompactionTokenThreshold =
asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000),
resumeAbandonedMaxAgeMinutes =
asLong(orchestrationSection["resume_abandoned_max_age_minutes"], 1_440),
compressionLevel = asInt(orchestrationSection["compression_level"], 4),
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], DEFAULT_STAGE_TIMEOUT_MS),
journalCompactionTokenThreshold = asInt(
orchestrationSection["journal_compaction_token_threshold"],
DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD,
),
resumeAbandonedMaxAgeMinutes = asLong(
orchestrationSection["resume_abandoned_max_age_minutes"],
DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES,
),
compressionLevel = asInt(orchestrationSection["compression_level"], DEFAULT_COMPRESSION_LEVEL),
tokenPrunerUrl =
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30),
readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3),
rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3),
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30),
repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8),
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20),
maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3),
reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20),
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], DEFAULT_MAX_TOOL_ROUNDS),
readLoopNudgeThreshold =
asInt(orchestrationSection["read_loop_nudge_threshold"], DEFAULT_READ_LOOP_NUDGE_THRESHOLD),
rejectionLoopNudgeThreshold = asInt(
orchestrationSection["rejection_loop_nudge_threshold"],
DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD,
),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], DEFAULT_MAX_FEEDBACK_ISSUES),
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], DEFAULT_REPO_MAP_INJECT_TOP_K),
repoMapFilesPerDir =
asInt(orchestrationSection["repo_map_files_per_dir"], DEFAULT_REPO_MAP_FILES_PER_DIR),
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], DEFAULT_DOCS_CATALOG_MAX),
maxClarificationRounds =
asInt(orchestrationSection["max_clarification_rounds"], DEFAULT_MAX_CLARIFICATION_ROUNDS),
reviewBlockMinConfidence =
asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP),
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET),
)
val modelsSettings = ModelsSettings(
defaultModel = asStringOrNull(modelsSection["default_model"]),
llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"),
host = asString(modelsSection["host"], "127.0.0.1"),
port = asInt(modelsSection["port"], 10000),
port = asInt(modelsSection["port"], DEFAULT_MODELS_PORT),
)
val artifacts = artifactsList.mapNotNull { artifactMap ->
@@ -664,8 +728,8 @@ object ConfigLoader {
val samplingSection = sections["sampling"] ?: emptyMap()
val sampling = SamplingConfig(
temperature = asDouble(samplingSection["temperature"], 0.7),
topP = asDouble(samplingSection["top_p"], 1.0),
temperature = asDouble(samplingSection["temperature"], DEFAULT_SAMPLING_TEMPERATURE),
topP = asDouble(samplingSection["top_p"], DEFAULT_SAMPLING_TOP_P),
topK = samplingSection["top_k"]?.let { asInt(it) },
minP = samplingSection["min_p"]?.let { asDouble(it) },
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
@@ -184,7 +184,11 @@ object EditableConfig {
"orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG,
getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() },
withString = { c, v ->
orc(c) { it.copy(resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)) }
orc(c) {
it.copy(
resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)
)
}
},
),
)
@@ -17,7 +17,9 @@ object OperatorProfileWriter {
}
val prefs = profile.preferences
val hasPrefs = prefs.approvalMode.isNotBlank() || prefs.preferredModels.isNotEmpty() || prefs.conventions.isNotEmpty()
val hasPrefs = prefs.approvalMode.isNotBlank() ||
prefs.preferredModels.isNotEmpty() ||
prefs.conventions.isNotEmpty()
if (hasPrefs) {
if (b.isNotEmpty()) b.append('\n')
b.append("[preferences]\n")
@@ -97,8 +97,11 @@ class DefaultContextPackBuilder(
// Stage 1 (FORMAT_COMPRESS): lossless json→dotted-line compaction of structured entries.
val formatted = if (policy.enabled(CompressionStage.FORMAT_COMPRESS)) {
deduped.map { entry ->
if (classifier.classify(entry) == ContextClass.STRUCTURED) reencode(entry, formatCompressor.compress(entry.content))
else entry
if (classifier.classify(entry) == ContextClass.STRUCTURED) {
reencode(entry, formatCompressor.compress(entry.content))
} else {
entry
}
}
} else deduped
@@ -142,7 +142,11 @@ class CompressionPipelineStagesTest {
// Instruction-doc pruning is retired: curated/procedural docs (project profile, AGENTS.md
// how-to) must reach the model verbatim — token-pruning fused them into unparseable soup.
val pruner = object : com.correx.core.context.compression.TokenPruner {
override suspend fun prune(content: String, protectedSpans: List<String>, targetRatio: Double): String = "DOC-PRUNED"
override suspend fun prune(
content: String,
protectedSpans: List<String>,
targetRatio: Double,
): String = "DOC-PRUNED"
}
val builder = com.correx.core.context.builder.DefaultContextPackBuilder(
com.correx.core.context.compression.DefaultContextCompressor(), CompressionPolicy(3), tokenPruner = pruner,
@@ -155,8 +159,11 @@ class CompressionPipelineStagesTest {
entry("agi", ContextLayer.L0, EntryRole.SYSTEM, bigInstructions).copy(sourceType = "agentInstructions"),
)
val flat = builder.build(
com.correx.core.events.types.ContextPackId("p"), com.correx.core.events.types.SessionId("s"),
com.correx.core.events.types.StageId("st"), entries, com.correx.core.context.model.TokenBudget(limit = 4000),
com.correx.core.events.types.ContextPackId("p"),
com.correx.core.events.types.SessionId("s"),
com.correx.core.events.types.StageId("st"),
entries,
com.correx.core.context.model.TokenBudget(limit = 4000),
).layers.values.flatten()
assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content)
assertEquals("you are an agent", flat.first { it.sourceType == "systemPrompt" }.content)
@@ -171,7 +178,10 @@ class CompressionPipelineStagesTest {
assertEquals(ContextClass.STRUCTURED, c.classify(entry("toolLog", ContextLayer.L2, EntryRole.TOOL)))
assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM)))
// Assistant tool-call turns are structured history, never token-pruned (amnesia-loop fix).
assertEquals(ContextClass.STRUCTURED, c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)))
assertEquals(
ContextClass.STRUCTURED,
c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)),
)
assertEquals(ContextClass.FREEFORM, c.classify(entry("chat", ContextLayer.L1, EntryRole.USER)))
}
}
@@ -3,14 +3,16 @@ package com.correx.core.approvals
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Suppress("MagicNumber")
@Serializable
enum class Tier(val level: Int) {
@SerialName("T0") T0(0),
@SerialName("T1") T1(1),
@SerialName("T2") T2(2),
@SerialName("T3") T3(3),
@SerialName("T4") T4(4)
enum class Tier {
@SerialName("T0") T0,
@SerialName("T1") T1,
@SerialName("T2") T2,
@SerialName("T3") T3,
@SerialName("T4") T4;
/** Escalation level, ordered T0 < T1 < … < T4. Derived from declaration order — no magic numbers. */
val level: Int get() = ordinal
}
fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level
@@ -19,4 +19,8 @@ data class ToolReceipt(
val tier: Tier,
val timestamp: Instant,
val diff: String? = null,
// Artifact-store hash of the FULL tool output when it was truncated for model context (the
// outputSummary above is a bounded preview). Null when nothing was truncated. Lets an agent
// retrieve everything via the tool_output tool without bloating the event log with raw output.
val fullOutputHash: String? = null,
)
@@ -28,6 +28,10 @@ class ArtifactRepairEventSerializationTest {
@Test
fun `ArtifactRepairFailed round-trips`() {
roundTrip(ArtifactRepairFailedEvent(ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st")))
roundTrip(
ArtifactRepairFailedEvent(
ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st"),
),
)
}
}
@@ -14,7 +14,11 @@ class PlanLintCompletedEventSerializationTest {
sessionId = SessionId("sess-1"),
candidateId = "freestyle-sess-1",
hardFailures = listOf(
PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"),
PlanLintFinding(
code = "unproduced_need",
stageId = "implement",
detail = "needs 'design', produced by no stage",
),
PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
),
softFindings = listOf(
@@ -44,7 +44,11 @@ class RepoKnowledgeRetrievedEventSerializationTest {
query = "context builder",
hits = listOf(
RepoKnowledgeHit(path = "core/context/ContextBuilder.kt", text = "class ContextBuilder", score = 0.95f),
RepoKnowledgeHit(path = "core/context/DefaultContextBuilder.kt", text = "class DefaultContextBuilder", score = 0.88f),
RepoKnowledgeHit(
path = "core/context/DefaultContextBuilder.kt",
text = "class DefaultContextBuilder",
score = 0.88f,
),
),
)
assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(),
@@ -28,7 +28,8 @@ class RepoMapComputedEventSerializationTest {
@Test
fun `RepoMapComputedEvent without stateKey field decodes with null`() {
val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo","entries":[],"computedAt":"2026-06-11T00:00:00Z"}"""
val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo",""" +
""""entries":[],"computedAt":"2026-06-11T00:00:00Z"}"""
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) as RepoMapComputedEvent
assertNull(decoded.stateKey)
}
@@ -23,7 +23,12 @@ class ToolCallAssessedEventSerializationTest {
observations = listOf(
ToolCallObservation(
ruleCode = "PATH_CONTAINMENT",
facts = mapOf("path" to "/tmp/x", "exists" to "false", "inWorkspace" to "false", "privileged" to "false"),
facts = mapOf(
"path" to "/tmp/x",
"exists" to "false",
"inWorkspace" to "false",
"privileged" to "false",
),
),
),
disposition = RiskAction.PROMPT_USER,
@@ -54,7 +54,12 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) {
* agents don't act on "Approval APPROVED (tier T2)" or "Advanced x → y", so they're
* dropped from the render (still present in [DecisionJournalState.records] for the journal).
*/
private val AGENT_RELEVANT_KINDS =
setOf(DecisionKind.INTENT, DecisionKind.STEERING, DecisionKind.PREEMPT, DecisionKind.FAILURE, DecisionKind.RETRY)
private val AGENT_RELEVANT_KINDS = setOf(
DecisionKind.INTENT,
DecisionKind.STEERING,
DecisionKind.PREEMPT,
DecisionKind.FAILURE,
DecisionKind.RETRY,
)
}
}
@@ -5,23 +5,13 @@ import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.inference.Tokenizer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
@@ -30,21 +20,15 @@ import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.ArtifactLifecyclePhase
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.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.subagent.InSessionSubagentRunner
import com.correx.core.kernel.orchestration.subagent.SubagentRunRequest
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
import com.correx.core.kernel.retry.FailureFingerprint
import com.correx.core.kernel.retry.RetryCoordinator
import com.correx.core.kernel.retry.RetryDecision
import com.correx.core.sessions.Session
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory
import java.util.UUID
@@ -70,37 +54,37 @@ private val log = LoggerFactory.getLogger(DefaultSessionOrchestrator::class.java
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
// opened the ticket (TICKET_RETURN). Both are kernel-driven, not plan edges — see ticketReturnMove.
internal val TICKET_ROUTE = TransitionId("ticket-route")
private val TICKET_RETURN = TransitionId("ticket-return")
internal val TICKET_RETURN = TransitionId("ticket-return")
// Shortest evidence token treated as a file reference — guards against matching a written path's
// suffix on a trivially short token (an extension fragment, a one-char name).
private const val MIN_EVIDENCE_TOKEN = 5
internal const val MIN_EVIDENCE_TOKEN = 5
// File-like tokens in gate evidence (tsc/build output): "src/App.tsx", "frontend/x.ts:12:5".
private val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
internal val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
// Deterministic gate id → capability the failing stage must hold to change the failure condition.
// A gate not listed here is not eligible for recovery routing (retries in place as before).
private val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
"execution" to "file_write",
"contract" to "file_write",
"static_analysis" to "file_write",
)
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
private fun ticketCategory(gate: String): String = when (gate) {
internal fun ticketCategory(gate: String): String = when (gate) {
"plan_compile" -> "planning"
else -> "implementation"
}
class DefaultSessionOrchestrator(
private val repositories: OrchestratorRepositories,
internal val repositories: OrchestratorRepositories,
engines: OrchestratorEngines,
private val retryCoordinator: RetryCoordinator,
internal val retryCoordinator: RetryCoordinator,
artifactStore: ArtifactStore,
tokenizer: Tokenizer? = null,
private val decisionJournalRepository: DefaultDecisionJournalRepository,
private val compactionService: JournalCompactionService? = null,
decisionJournalRepository: DefaultDecisionJournalRepository,
internal val compactionService: JournalCompactionService? = null,
artifactKindRegistry: ArtifactKindRegistry? = null,
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
readyTaskCounter: ReadyTaskCounter? = null,
@@ -114,7 +98,7 @@ class DefaultSessionOrchestrator(
// Hybrid-exhaustion salvage judge (T6/T7): consulted only when the "review" gate exhausts its
// per-gate retry budget. Null = deterministic allow-one-reset-then-fail fallback (see
// decideGateExhaustion) so the feature degrades safely without inference.
private val salvageJudge: SalvageJudge? = engines.salvageJudge
internal val salvageJudge: SalvageJudge? = engines.salvageJudge
override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
executeStage = { sid, stg, graph, session, cfg ->
@@ -140,336 +124,6 @@ class DefaultSessionOrchestrator(
}
}
@Suppress("LongMethod")
private tailrec suspend fun step(ctx: EnrichedExecutionContext): WorkflowResult {
log.debug(
"[Orchestrator] step session={} stage={} stageCount={}",
ctx.sessionId.value, ctx.currentStageId.value, ctx.stageCount,
)
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
val enriched = EnrichedExecutionContext(
graph = ctx.graph,
sessionId = ctx.sessionId,
stageCount = ctx.stageCount,
currentStageId = ctx.currentStageId,
config = ctx.config,
state = orchestrationRepository.getState(ctx.sessionId),
session = repositories.sessionRepository.getSession(ctx.sessionId),
)
val stageConfig = enriched.graph.stages[enriched.currentStageId]
val targetIds: Set<ArtifactId> = stageConfig?.produces?.map { it.name }?.toSet() ?: emptySet()
val stageArtifacts: Map<ArtifactId, ArtifactState> = if (targetIds.isEmpty()) {
emptyMap()
} else {
val validated = repositories.eventStore.read(enriched.sessionId)
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId }
.filter { it in targetIds }
.toSet()
validated.associateWith { ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) }
}
val artifactContent: Map<ArtifactId, String> = targetIds.mapNotNull { id ->
val cacheKey = "${enriched.sessionId.value}:${id.value}"
artifactContentCache[cacheKey]?.let { content -> id to content }
}.toMap()
val resolved = resolveTransition(
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
)
// Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
// is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
// that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
// implementer or skip intervening stages. Derived from the latest failure ticket routed here
// (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
val baseDecision = ticketReturnMove(enriched) ?: resolved
// 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,
)
baseDecision
}
PreemptRedirect.Outcome.None -> baseDecision
}
log.debug(
"[Orchestrator] transition session={} stage={} decision={}",
enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
)
return when (decision) {
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
is StepResult.Continue -> step(r.ctx)
is StepResult.Terminal -> r.result
}
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
} else {
// No outgoing edge matched: the stage finished but produced nothing that satisfies a
// transition (e.g. the analyst never emitted a validated `analysis`). Treat it as a
// retryable stage failure — a fresh attempt lets the model gather more before writing —
// rather than killing the run. Only terminal once the retry budget is spent.
retryStageOrFail(
enriched,
"no transition condition matched from stage ${enriched.currentStageId.value}",
)
}
is TransitionDecision.Blocked -> failWorkflow(
sessionId = enriched.sessionId,
stageId = enriched.currentStageId,
reason = decision.reason,
retryExhausted = false,
)
is TransitionDecision.NoMatch -> retryStageOrFail(
enriched,
"no matching transition from stage ${enriched.currentStageId.value}",
)
}
}
/**
* A non-terminal stage whose outcome matched no outgoing transition (Stay/NoMatch) produced no
* usable artifact. Route it back through the stage's retry budget instead of failing the run:
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
*/
private suspend fun retryStageOrFail(
ctx: EnrichedExecutionContext,
reason: String,
): WorkflowResult {
val attempt = orchestrationRepository.getState(ctx.sessionId).retryCount
val shouldRetry = retryCoordinator.shouldRetry(
sessionId = ctx.sessionId,
stageId = ctx.currentStageId,
currentAttempt = attempt,
policy = ctx.config.retryPolicy,
failureReason = reason,
)
if (!shouldRetry) {
return failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true)
}
return when (val r = enterStage(ctx, ctx.currentStageId)) {
is StepResult.Continue -> step(r.ctx)
is StepResult.Terminal -> r.result
}
}
/**
* Retry-agency guard (ticket + recovery model). When [failure]'s gate requires a capability the
* failing stage does not hold (e.g. a build/contract gate needs `file_write` on a write-less
* verifier), retrying the stage in place can never change the outcome. Instead open a
* [FailureTicketOpenedEvent] and route to the graph's recovery stage — which does hold the
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
*
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
*/
private suspend fun maybeRouteToRecovery(
ctx: EnrichedExecutionContext,
stageId: StageId,
failure: StageExecutionResult.Failure,
state: OrchestrationState,
): StepResult? {
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
}
/**
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
*
* **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
* to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
* patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
*
* **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
* failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
* internally consistent; they disagree with each other). The ticket escalates to the synthesized
* recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
* authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
* Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
*
* Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
* one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
* budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
* recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
* guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
*/
private suspend fun routeToRecovery(
ctx: EnrichedExecutionContext,
stageId: StageId,
gate: String,
requiredCapability: String,
reason: String,
state: OrchestrationState,
): StepResult? {
val events = repositories.eventStore.read(ctx.sessionId)
val owner = resolveTicketOwner(events, reason)
?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
val arbiter = findRecoveryStage(ctx.graph, stageId)
val fingerprint = FailureFingerprint.of(reason)
val ownerKey = stageId.value
val intentKey = stageId.value + INTENT_BUDGET_SUFFIX
// (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
// the arbiter tier once the owner loop is spent; null = no tier available now.
val route = when {
owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
else -> null
}
if (route == null) {
if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
return StepResult.Terminal(
failWorkflow(
ctx.sessionId,
stageId,
"repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
retryExhausted = true,
),
)
}
val prev = state.recoveryFailureFingerprints[route.budgetKey]
val progressed = prev != null && prev != fingerprint
val used = state.recoveryRoutes[route.budgetKey] ?: 0
val routeAttempt = if (progressed) used else used + 1
emit(
ctx.sessionId,
FailureTicketOpenedEvent(
sessionId = ctx.sessionId,
stageId = stageId,
gate = gate,
category = ticketCategory(gate),
requiredCapability = requiredCapability,
routeTo = route.target,
evidence = reason,
routeAttempt = routeAttempt,
fingerprint = fingerprint,
escalated = route.escalated,
),
)
log.info(
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
"(charged={}/{}, progressed={})",
ctx.sessionId.value, stageId.value, gate, requiredCapability,
if (route.escalated) "arbiter" else "owner", route.target.value,
routeAttempt, route.budget, progressed,
)
val advancedTo = advanceStage(
ctx.sessionId,
stageId,
TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
)
return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
}
/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
private data class RouteTier(
val target: StageId,
val budgetKey: String,
val budget: Int,
val escalated: Boolean,
)
/**
* Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
* the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
* exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
*/
private fun budgetExhausted(
state: OrchestrationState,
key: String,
fingerprint: String,
budget: Int,
): Boolean {
val prev = state.recoveryFailureFingerprints[key]
val progressed = prev != null && prev != fingerprint
val used = state.recoveryRoutes[key] ?: 0
return !progressed && used >= budget
}
/**
* The graph's recovery stage: a stage marked `metadata["role"] == "recovery"` (or, as a
* convenience, one whose id is literally "recovery"), other than [failingStageId] itself.
* Null when the graph declares none — recovery routing is opt-in per workflow.
*/
private fun findRecoveryStage(graph: WorkflowGraph, failingStageId: StageId): StageId? =
graph.stages.entries.firstOrNull { (id, cfg) ->
id != failingStageId && (cfg.metadata["role"] == "recovery" || id.value == "recovery")
}?.key
/**
* Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
* files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
* via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
* invocationId → path). The last write of a named file wins — its author is who to hand the
* ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
* written file (an orphan failure — caller falls back to the synthesized recovery stage).
*/
private fun resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
val tokens = EVIDENCE_PATH_RE.findAll(evidence)
.map { it.value.substringBefore(':').replace('\\', '/') }
.filter { it.length >= MIN_EVIDENCE_TOKEN }
.toSet()
if (tokens.isEmpty()) return null
val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.associate { it.invocationId to it.stageId }
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.postImageHash != null }
.lastOrNull { fw ->
val norm = fw.path.replace('\\', '/')
invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
}
?.let { invToStage[it.invocationId] }
}
/**
* Return-to-sender for a routed repair stage. When the current stage was entered by a failure
* ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
* ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
* holds for any repair destination — the file's author OR the fallback recovery stage — and for
* arbitrary topology, instead of following the plan's forward edge (which would walk a
* regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
* entered via a ticket (a normal forward visit — the resolver's edge is used).
*/
private fun ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
val events = repositories.eventStore.read(ctx.sessionId)
val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
.lastOrNull { it.to == ctx.currentStageId }
?.transitionId == TICKET_ROUTE
if (!enteredViaTicket) return null
val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
.lastOrNull { it.routeTo == ctx.currentStageId }
?.stageId ?: return null
return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
}
/** Returns the cached validated artifact content for the given session + artifact id, or null if absent. */
fun validatedArtifactContent(sessionId: SessionId, artifactId: ArtifactId): String? =
artifactContentCache["${sessionId.value}:${artifactId.value}"]
@@ -599,193 +253,6 @@ class DefaultSessionOrchestrator(
)
}
private suspend fun executeMove(
ctx: EnrichedExecutionContext,
decision: TransitionDecision.Move,
): StepResult {
val nextStageId = decision.to
if (isTerminal(ctx.graph, nextStageId)) {
// Terminal stage is a sentinel — not executed, so don't count it.
return StepResult.Terminal(
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
)
}
// Runtime refinement guard: a back-edge (re-entering a stage already visited this
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
// terminal failure instead of looping forever.
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
if (iteration > maxIterations) {
return StepResult.Terminal(
failWorkflow(
ctx.sessionId,
nextStageId,
"refinement loop '$cycleKey' exceeded $maxIterations iterations — escalating",
retryExhausted = true,
),
)
}
}
// Emit the transition before executing the next stage. Otherwise the next stage's
// events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted
// that marks entering it, so the log shows the stage running before it was entered.
val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision)
return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId)) {
is StepResult.Continue -> StepResult.Continue(result.ctx)
is StepResult.Terminal -> result
}
}
@Suppress("ReturnCount")
private suspend fun enterStage(
ctx: EnrichedExecutionContext,
stageId: StageId,
): StepResult {
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
val stageRequestIds = events
.mapNotNull { it.payload as? ApprovalRequestedEvent }
.filter { it.stageId == stageId && it.toolName == null }
.map { it.requestId }
.toSet()
stageRequestIds.isNotEmpty() && events.any {
val decision = it.payload as? ApprovalDecisionResolvedEvent
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
decision?.requestId in stageRequestIds &&
decision?.outcome != ApprovalOutcome.REJECTED
}
}
if (!alreadyApproved) {
val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
val preview = gateArtifact
?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
val approved = requestStageApproval(ctx.sessionId, stageId, preview)
if (!approved) {
return StepResult.Terminal(
failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
)
}
}
}
while (true) {
if (isCancelled(ctx.sessionId)) {
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
}
val result = subagentRunner.run(
SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
).outcome
when (result) {
is StageExecutionResult.Success -> {
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
// The stage raised open questions and the operator answered them; loop to
// re-run the stage with the answers injected (no failure-retry budget spent).
continue
}
compactionService?.let { svc ->
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
val journalText = DecisionJournalRenderer().render(journalState)
val tokenEstimate = estimateTokens(journalText)
svc.compactIfNeeded(
sessionId = ctx.sessionId,
state = journalState,
renderedTokenEstimate = tokenEstimate,
emit = { payload -> emit(ctx.sessionId, payload) },
)
}
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
}
is StageExecutionResult.Failure -> {
log.debug(
"[Orchestrator] stage failed session=${ctx.sessionId.value} " +
"stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
)
if (!result.retryable) {
// Non-retryable: let the transition resolver handle the outcome
// (e.g., back-edge via artifact_field_equals on failure)
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
}
val refreshedState = orchestrationRepository.getState(ctx.sessionId)
// Retry-agency invariant: a gate this stage lacks the capability to fix must not
// be retried in place (futile). Route to a recovery stage that holds the
// capability, if one exists and the route budget remains.
maybeRouteToRecovery(ctx, stageId, result, refreshedState)?.let { return it }
val gateDecision = retryCoordinator.decide(
sessionId = ctx.sessionId,
stageId = stageId,
gate = result.gate,
failureReason = result.reason,
state = refreshedState,
policy = ctx.config.retryPolicy,
)
when (gateDecision) {
RetryDecision.Retry -> Unit // retry — loop and re-execute
RetryDecision.Exhausted -> {
decideGateExhaustion(
ctx, stageId, result.gate, result.reason, refreshedState,
)?.let { return it }
// review-gate salvage said CONTINUE — budget was reset by the reducer;
// loop and re-execute the stage with a fresh budget.
}
}
}
}
}
}
/**
* Hybrid exhaustion (design 2026-07-06-per-gate-retry-budgets.md §3/T6): a deterministic gate
* exhausting its budget fails the stage terminally, unchanged from today's behaviour (just
* per-gate now). The "review" gate instead gets one salvageability judgement — CONTINUE resets
* its budget (via the reducer folding [RetrySalvageDecidedEvent]) and the caller loops to retry;
* FAIL is terminal. A gate that already spent its one salvage reset (state.gateSalvageUsed) fails
* immediately on a second exhaustion, regardless of what the judge would say.
*
* Returns a terminal [StepResult] to fail/route with, the recovery stage's result on RECOVER,
* or null to retry in place (loop and re-execute).
*/
private suspend fun decideGateExhaustion(
ctx: EnrichedExecutionContext,
stageId: StageId,
gate: String,
reason: String,
state: OrchestrationState,
): StepResult? {
val sessionId = ctx.sessionId
if (gate != "review" || state.gateSalvageUsed.contains(gate)) {
return StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
}
// No judge wired: degrade safely with a deterministic allow-one-reset-then-fail policy —
// the gateSalvageUsed check above already ensures this fires at most once per gate.
val judgment = salvageJudge?.judge(sessionId, stageId, reason)
?: SalvageJudgment(
SalvageDecision.CONTINUE,
"no salvage judge wired — deterministic one-time reset",
)
emit(sessionId, RetrySalvageDecidedEvent(sessionId, stageId, gate, judgment.decision, judgment.rationale))
return when (judgment.decision) {
SalvageDecision.CONTINUE -> null
SalvageDecision.FAIL -> StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
// The judge chose recovery: route to the recovery stage (file_write is the capability it
// provides). Degrade to terminal if the graph declares no recovery stage.
SalvageDecision.RECOVER ->
routeToRecovery(ctx, stageId, gate, "file_write", reason, state)
?: StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
}
}
private fun ExecutionContext.enrich() = EnrichedExecutionContext(
graph, sessionId, stageCount, currentStageId, config,
session = repositories.sessionRepository.getSession(sessionId),
@@ -795,15 +262,15 @@ class DefaultSessionOrchestrator(
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
private fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
internal fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
private sealed class StepResult {
internal sealed class StepResult {
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
data class Terminal(val result: WorkflowResult) : StepResult()
}
private data class ExecutionContext(
internal data class ExecutionContext(
val graph: WorkflowGraph,
val sessionId: SessionId,
val stageCount: Int,
@@ -813,7 +280,7 @@ private data class ExecutionContext(
val state: OrchestrationState?,
)
private data class EnrichedExecutionContext(
internal data class EnrichedExecutionContext(
val graph: WorkflowGraph,
val sessionId: SessionId,
val stageCount: Int,
@@ -0,0 +1,238 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.retry.FailureFingerprint
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
ctx: EnrichedExecutionContext,
reason: String,
): WorkflowResult {
val attempt = orchestrationRepository.getState(ctx.sessionId).retryCount
val shouldRetry = retryCoordinator.shouldRetry(
sessionId = ctx.sessionId,
stageId = ctx.currentStageId,
currentAttempt = attempt,
policy = ctx.config.retryPolicy,
failureReason = reason,
)
if (!shouldRetry) {
return failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true)
}
return when (val r = enterStage(ctx, ctx.currentStageId)) {
is StepResult.Continue -> step(r.ctx)
is StepResult.Terminal -> r.result
}
}
/**
* Retry-agency guard (ticket + recovery model). When [failure]'s gate requires a capability the
* failing stage does not hold (e.g. a build/contract gate needs `file_write` on a write-less
* verifier), retrying the stage in place can never change the outcome. Instead open a
* [FailureTicketOpenedEvent] and route to the graph's recovery stage — which does hold the
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
*
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
*/
internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
ctx: EnrichedExecutionContext,
stageId: StageId,
failure: StageExecutionResult.Failure,
state: OrchestrationState,
): StepResult? {
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
}
/**
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
*
* **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
* to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
* patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
*
* **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
* failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
* internally consistent; they disagree with each other). The ticket escalates to the synthesized
* recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
* authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
* Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
*
* Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
* one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
* budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
* recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
* guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
*/
internal suspend fun DefaultSessionOrchestrator.routeToRecovery(
ctx: EnrichedExecutionContext,
stageId: StageId,
gate: String,
requiredCapability: String,
reason: String,
state: OrchestrationState,
): StepResult? {
val events = repositories.eventStore.read(ctx.sessionId)
val owner = resolveTicketOwner(events, reason)
?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
val arbiter = findRecoveryStage(ctx.graph, stageId)
val fingerprint = FailureFingerprint.of(reason)
val ownerKey = stageId.value
val intentKey = stageId.value + INTENT_BUDGET_SUFFIX
// (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
// the arbiter tier once the owner loop is spent; null = no tier available now.
val route = when {
owner != null && !budgetExhausted(state, ownerKey, fingerprint, tuning.recoveryRouteBudget) ->
RouteTier(owner, ownerKey, tuning.recoveryRouteBudget, escalated = false)
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, tuning.intentRouteBudget) ->
RouteTier(arbiter, intentKey, tuning.intentRouteBudget, escalated = true)
else -> null
}
if (route == null) {
if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
return StepResult.Terminal(
failWorkflow(
ctx.sessionId,
stageId,
"repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
retryExhausted = true,
),
)
}
val prev = state.recoveryFailureFingerprints[route.budgetKey]
val progressed = prev != null && prev != fingerprint
val used = state.recoveryRoutes[route.budgetKey] ?: 0
val routeAttempt = if (progressed) used else used + 1
emit(
ctx.sessionId,
FailureTicketOpenedEvent(
sessionId = ctx.sessionId,
stageId = stageId,
gate = gate,
category = ticketCategory(gate),
requiredCapability = requiredCapability,
routeTo = route.target,
evidence = reason,
routeAttempt = routeAttempt,
fingerprint = fingerprint,
escalated = route.escalated,
),
)
log.info(
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
"(charged={}/{}, progressed={})",
ctx.sessionId.value, stageId.value, gate, requiredCapability,
if (route.escalated) "arbiter" else "owner", route.target.value,
routeAttempt, route.budget, progressed,
)
val advancedTo = advanceStage(
ctx.sessionId,
stageId,
TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
)
return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
}
/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
private data class RouteTier(
val target: StageId,
val budgetKey: String,
val budget: Int,
val escalated: Boolean,
)
/**
* Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
* the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
* exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
*/
internal fun DefaultSessionOrchestrator.budgetExhausted(
state: OrchestrationState,
key: String,
fingerprint: String,
budget: Int,
): Boolean {
val prev = state.recoveryFailureFingerprints[key]
val progressed = prev != null && prev != fingerprint
val used = state.recoveryRoutes[key] ?: 0
return !progressed && used >= budget
}
/**
* The graph's recovery stage: a stage marked `metadata["role"] == "recovery"` (or, as a
* convenience, one whose id is literally "recovery"), other than [failingStageId] itself.
* Null when the graph declares none — recovery routing is opt-in per workflow.
*/
internal fun DefaultSessionOrchestrator.findRecoveryStage(graph: WorkflowGraph, failingStageId: StageId): StageId? =
graph.stages.entries.firstOrNull { (id, cfg) ->
id != failingStageId && (cfg.metadata["role"] == "recovery" || id.value == "recovery")
}?.key
/**
* Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
* files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
* via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
* invocationId → path). The last write of a named file wins — its author is who to hand the
* ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
* written file (an orphan failure — caller falls back to the synthesized recovery stage).
*/
internal fun DefaultSessionOrchestrator.resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
val tokens = EVIDENCE_PATH_RE.findAll(evidence)
.map { it.value.substringBefore(':').replace('\\', '/') }
.filter { it.length >= MIN_EVIDENCE_TOKEN }
.toSet()
if (tokens.isEmpty()) return null
val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.associate { it.invocationId to it.stageId }
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.postImageHash != null }
.lastOrNull { fw ->
val norm = fw.path.replace('\\', '/')
invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
}
?.let { invToStage[it.invocationId] }
}
/**
* Return-to-sender for a routed repair stage. When the current stage was entered by a failure
* ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
* ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
* holds for any repair destination — the file's author OR the fallback recovery stage — and for
* arbitrary topology, instead of following the plan's forward edge (which would walk a
* regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
* entered via a ticket (a normal forward visit — the resolver's edge is used).
*/
internal fun DefaultSessionOrchestrator.ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
val events = repositories.eventStore.read(ctx.sessionId)
val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
.lastOrNull { it.to == ctx.currentStageId }
?.transitionId == TICKET_ROUTE
if (!enteredViaTicket) return null
val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
.lastOrNull { it.routeTo == ctx.currentStageId }
?.stageId ?: return null
return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
}
@@ -0,0 +1,324 @@
package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.artifacts.ArtifactState
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.subagent.SubagentRunRequest
import com.correx.core.kernel.retry.RetryDecision
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.resolution.TransitionDecision
import kotlinx.datetime.Clock
import java.util.concurrent.*
import java.util.concurrent.atomic.*
@Suppress("LongMethod")
internal tailrec suspend fun DefaultSessionOrchestrator.step(ctx: EnrichedExecutionContext): WorkflowResult {
log.debug(
"[Orchestrator] step session={} stage={} stageCount={}",
ctx.sessionId.value, ctx.currentStageId.value, ctx.stageCount,
)
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
val enriched = EnrichedExecutionContext(
graph = ctx.graph,
sessionId = ctx.sessionId,
stageCount = ctx.stageCount,
currentStageId = ctx.currentStageId,
config = ctx.config,
state = orchestrationRepository.getState(ctx.sessionId),
session = repositories.sessionRepository.getSession(ctx.sessionId),
)
val stageConfig = enriched.graph.stages[enriched.currentStageId]
val targetIds: Set<ArtifactId> = stageConfig?.produces?.map { it.name }?.toSet() ?: emptySet()
val stageArtifacts: Map<ArtifactId, ArtifactState> = if (targetIds.isEmpty()) {
emptyMap()
} else {
val validated = repositories.eventStore.read(enriched.sessionId)
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId }
.filter { it in targetIds }
.toSet()
validated.associateWith { ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED) }
}
val artifactContent: Map<ArtifactId, String> = targetIds.mapNotNull { id ->
val cacheKey = "${enriched.sessionId.value}:${id.value}"
artifactContentCache[cacheKey]?.let { content -> id to content }
}.toMap()
val resolved = resolveTransition(
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
)
// Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
// is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
// that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
// implementer or skip intervening stages. Derived from the latest failure ticket routed here
// (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
val baseDecision = ticketReturnMove(enriched) ?: resolved
// 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,
)
baseDecision
}
PreemptRedirect.Outcome.None -> baseDecision
}
log.debug(
"[Orchestrator] transition session={} stage={} decision={}",
enriched.sessionId.value, enriched.currentStageId.value, decision::class.simpleName,
)
return when (decision) {
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
is StepResult.Continue -> step(r.ctx)
is StepResult.Terminal -> r.result
}
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
} else {
// No outgoing edge matched: the stage finished but produced nothing that satisfies a
// transition (e.g. the analyst never emitted a validated `analysis`). Treat it as a
// retryable stage failure — a fresh attempt lets the model gather more before writing —
// rather than killing the run. Only terminal once the retry budget is spent.
retryStageOrFail(
enriched,
"no transition condition matched from stage ${enriched.currentStageId.value}",
)
}
is TransitionDecision.Blocked -> failWorkflow(
sessionId = enriched.sessionId,
stageId = enriched.currentStageId,
reason = decision.reason,
retryExhausted = false,
)
is TransitionDecision.NoMatch -> retryStageOrFail(
enriched,
"no matching transition from stage ${enriched.currentStageId.value}",
)
}
}
/**
* A non-terminal stage whose outcome matched no outgoing transition (Stay/NoMatch) produced no
* usable artifact. Route it back through the stage's retry budget instead of failing the run:
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
*/
internal suspend fun DefaultSessionOrchestrator.executeMove(
ctx: EnrichedExecutionContext,
decision: TransitionDecision.Move,
): StepResult {
val nextStageId = decision.to
if (isTerminal(ctx.graph, nextStageId)) {
// Terminal stage is a sentinel — not executed, so don't count it.
return StepResult.Terminal(
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
)
}
// Runtime refinement guard: a back-edge (re-entering a stage already visited this
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
// terminal failure instead of looping forever.
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
if (iteration > maxIterations) {
return StepResult.Terminal(
failWorkflow(
ctx.sessionId,
nextStageId,
"refinement loop '$cycleKey' exceeded $maxIterations iterations — escalating",
retryExhausted = true,
),
)
}
}
// Emit the transition before executing the next stage. Otherwise the next stage's
// events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted
// that marks entering it, so the log shows the stage running before it was entered.
val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision)
return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId)) {
is StepResult.Continue -> StepResult.Continue(result.ctx)
is StepResult.Terminal -> result
}
}
@Suppress("ReturnCount")
internal suspend fun DefaultSessionOrchestrator.enterStage(
ctx: EnrichedExecutionContext,
stageId: StageId,
): StepResult {
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
if (ctx.graph.stages[stageId]?.metadata?.get("requiresApproval") == "true") {
val alreadyApproved = repositories.eventStore.read(ctx.sessionId).let { events ->
val stageRequestIds = events
.mapNotNull { it.payload as? ApprovalRequestedEvent }
.filter { it.stageId == stageId && it.toolName == null }
.map { it.requestId }
.toSet()
stageRequestIds.isNotEmpty() && events.any {
val decision = it.payload as? ApprovalDecisionResolvedEvent
// A REJECTED decision must NOT satisfy the gate on retry/resume, else the stage
// runs unapproved. Only APPROVED/AUTO_APPROVED count as a prior approval.
decision?.requestId in stageRequestIds &&
decision?.outcome != ApprovalOutcome.REJECTED
}
}
if (!alreadyApproved) {
val gateArtifact = ctx.graph.stages[stageId]?.needs?.firstOrNull()?.value
val preview = gateArtifact
?.let { artifactContentCache["${ctx.sessionId.value}:$it"] }
?: "Upstream stage has completed. Review and approve to continue to stage ${stageId.value}."
val approved = requestStageApproval(ctx.sessionId, stageId, preview)
if (!approved) {
return StepResult.Terminal(
failWorkflow(ctx.sessionId, stageId, "approval rejected for stage ${stageId.value}", retryExhausted = false)
)
}
}
}
while (true) {
if (isCancelled(ctx.sessionId)) {
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
}
val result = subagentRunner.run(
SubagentRunRequest(ctx.sessionId, stageId, ctx.graph, ctx.session, ctx.config),
).outcome
when (result) {
is StageExecutionResult.Success -> {
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
// The stage raised open questions and the operator answered them; loop to
// re-run the stage with the answers injected (no failure-retry budget spent).
continue
}
compactionService?.let { svc ->
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
val journalText = DecisionJournalRenderer().render(journalState)
val tokenEstimate = estimateTokens(journalText)
svc.compactIfNeeded(
sessionId = ctx.sessionId,
state = journalState,
renderedTokenEstimate = tokenEstimate,
emit = { payload -> emit(ctx.sessionId, payload) },
)
}
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
}
is StageExecutionResult.Failure -> {
log.debug(
"[Orchestrator] stage failed session=${ctx.sessionId.value} " +
"stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
)
if (!result.retryable) {
// Non-retryable: let the transition resolver handle the outcome
// (e.g., back-edge via artifact_field_equals on failure)
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
}
val refreshedState = orchestrationRepository.getState(ctx.sessionId)
// Retry-agency invariant: a gate this stage lacks the capability to fix must not
// be retried in place (futile). Route to a recovery stage that holds the
// capability, if one exists and the route budget remains.
maybeRouteToRecovery(ctx, stageId, result, refreshedState)?.let { return it }
val gateDecision = retryCoordinator.decide(
sessionId = ctx.sessionId,
stageId = stageId,
gate = result.gate,
failureReason = result.reason,
state = refreshedState,
policy = ctx.config.retryPolicy,
)
when (gateDecision) {
RetryDecision.Retry -> Unit // retry — loop and re-execute
RetryDecision.Exhausted -> {
decideGateExhaustion(
ctx, stageId, result.gate, result.reason, refreshedState,
)?.let { return it }
// review-gate salvage said CONTINUE — budget was reset by the reducer;
// loop and re-execute the stage with a fresh budget.
}
}
}
}
}
}
/**
* Hybrid exhaustion (design 2026-07-06-per-gate-retry-budgets.md §3/T6): a deterministic gate
* exhausting its budget fails the stage terminally, unchanged from today's behaviour (just
* per-gate now). The "review" gate instead gets one salvageability judgement — CONTINUE resets
* its budget (via the reducer folding [RetrySalvageDecidedEvent]) and the caller loops to retry;
* FAIL is terminal. A gate that already spent its one salvage reset (state.gateSalvageUsed) fails
* immediately on a second exhaustion, regardless of what the judge would say.
*
* Returns a terminal [StepResult] to fail/route with, the recovery stage's result on RECOVER,
* or null to retry in place (loop and re-execute).
*/
internal suspend fun DefaultSessionOrchestrator.decideGateExhaustion(
ctx: EnrichedExecutionContext,
stageId: StageId,
gate: String,
reason: String,
state: OrchestrationState,
): StepResult? {
val sessionId = ctx.sessionId
if (gate != "review" || state.gateSalvageUsed.contains(gate)) {
return StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
}
// No judge wired: degrade safely with a deterministic allow-one-reset-then-fail policy —
// the gateSalvageUsed check above already ensures this fires at most once per gate.
val judgment = salvageJudge?.judge(sessionId, stageId, reason)
?: SalvageJudgment(
SalvageDecision.CONTINUE,
"no salvage judge wired — deterministic one-time reset",
)
emit(sessionId, RetrySalvageDecidedEvent(sessionId, stageId, gate, judgment.decision, judgment.rationale))
return when (judgment.decision) {
SalvageDecision.CONTINUE -> null
SalvageDecision.FAIL -> StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
// The judge chose recovery: route to the recovery stage (file_write is the capability it
// provides). Degrade to terminal if the graph declares no recovery stage.
SalvageDecision.RECOVER ->
routeToRecovery(ctx, stageId, gate, "file_write", reason, state)
?: StepResult.Terminal(failWorkflow(sessionId, stageId, reason, retryExhausted = true))
}
}
@@ -24,6 +24,7 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -81,12 +82,14 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict {
val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() }
?: return verdict(false, "no scripts block")
return verdict(scripts.containsKey(name), if (scripts.containsKey(name)) "script '$name' defined" else "missing script '$name'")
val hasScript = scripts.containsKey(name)
return verdict(hasScript, if (hasScript) "script '$name' defined" else "missing script '$name'")
}
private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
return verdict(text.contains(pattern), if (text.contains(pattern)) "contains '$pattern'" else "missing '$pattern'")
val hasPattern = text.contains(pattern)
return verdict(hasPattern, if (hasPattern) "contains '$pattern'" else "missing '$pattern'")
}
private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict {
@@ -158,7 +158,9 @@ class ReplayOrchestrator(
session: Session,
): ReplayStepResult {
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}")
return when (val result = executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config))) {
val result =
executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config))
return when (result) {
is StageExecutionResult.Success -> ReplayStepResult.Continue(
ctx.copy(stageCount = ctx.stageCount + 1),
)
@@ -0,0 +1,195 @@
package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
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.risk.RiskSummary
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.RiskSummaryId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.sessions.ApprovalMode
import com.correx.core.risk.RiskContext
import com.correx.core.risk.toApprovalTier
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.validation.pipeline.ValidationOutcome
import kotlinx.coroutines.CompletableDeferred
import kotlinx.datetime.Clock
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
@Suppress("CyclomaticComplexMethod")
// Maps the operator profile's free-text approval_mode onto the engine's [ApprovalMode].
// Unset/unknown falls back to PROMPT so an absent profile keeps the human-in-the-loop
// default; only an explicit auto/yolo opts a session into unattended approval.
internal fun SessionOrchestrator.approvalModeFor(profileMode: String?): ApprovalMode =
when (profileMode?.trim()?.lowercase()) {
"deny" -> ApprovalMode.DENY
"auto" -> ApprovalMode.AUTO
"yolo" -> ApprovalMode.YOLO
else -> ApprovalMode.PROMPT
}
internal suspend fun SessionOrchestrator.requestStageApproval(
sessionId: SessionId,
stageId: StageId,
preview: String,
): Boolean {
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val validationReportId = ValidationReportId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[requestId] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
ApprovalRequestedEvent(
requestId = requestId,
tier = Tier.T2,
validationReportId = validationReportId,
riskSummaryId = null,
riskSummary = null,
sessionId = sessionId,
stageId = stageId,
projectId = null,
preview = preview,
),
)
return try {
val decision = deferred.await()
emitDecisionResolved(
sessionId,
DomainApprovalRequest(
id = requestId,
tier = Tier.T2,
validationReportId = validationReportId,
riskSummaryId = null,
riskSummary = null,
timestamp = Clock.System.now(),
),
decision,
)
if (decision.isApproved) {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
true
} else {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
false
}
} finally {
pendingApprovals.remove(requestId)
}
}
// --- private functions ---
internal suspend fun SessionOrchestrator.handleApproval(
sessionId: SessionId,
stageId: StageId,
outcome: ValidationOutcome.NeedsApproval,
): StageExecutionResult {
log.debug("[Orchestrator] approval required session={} stage={}", sessionId.value, stageId.value)
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[domainRequest.id] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
ApprovalRequestedEvent(
requestId = domainRequest.id,
tier = domainRequest.tier,
validationReportId = domainRequest.validationReportId,
riskSummaryId = domainRequest.riskSummaryId,
riskSummary = domainRequest.riskSummary,
sessionId = sessionId,
stageId = stageId,
projectId = null,
),
)
return try {
log.debug(
"[Orchestrator] awaiting approval session={} requestId={}",
sessionId.value, domainRequest.id.value,
)
val decision = deferred.await()
log.debug(
"[Orchestrator] approval resolved session={} approved={}",
sessionId.value, decision.isApproved,
)
emitDecisionResolved(sessionId, domainRequest, decision)
if (decision.isApproved) {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
StageExecutionResult.Success(emptyList())
} else {
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
StageExecutionResult.Failure(decision.reason ?: "approval rejected", retryable = false)
}
} finally {
pendingApprovals.remove(domainRequest.id)
}
}
internal suspend fun SessionOrchestrator.buildApprovalRequest(
sessionId: SessionId,
stageId: StageId,
outcome: ValidationOutcome.NeedsApproval,
): DomainApprovalRequest {
val state = orchestrationRepository.getState(sessionId)
val inferenceState = inferenceRepository.getInferenceState(sessionId)
val riskSummary = riskAssessor.assess(
RiskContext(
validationReport = outcome.request.validationReport,
orchestrationState = state,
inferenceState = inferenceState,
),
)
val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString())
// RiskAssessedEvent records the indexed level+action summary; ApprovalRequestedEvent carries the full
// authoritative RiskSummary (signals + rationale). Both derive from the same assessment value so
// they cannot diverge.
emit(
sessionId,
RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction),
)
return DomainApprovalRequest(
id = ApprovalRequestId(UUID.randomUUID().toString()),
tier = riskSummary.level.toApprovalTier(),
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = riskSummaryId,
riskSummary = riskSummary,
timestamp = Clock.System.now(),
)
}
internal suspend fun SessionOrchestrator.emitDecisionResolved(
sessionId: SessionId,
domainRequest: DomainApprovalRequest,
decision: ApprovalDecision,
) {
emit(
sessionId,
ApprovalDecisionResolvedEvent(
decisionId = ApprovalDecisionId(UUID.randomUUID().toString()),
requestId = domainRequest.id,
outcome = decision.outcome ?: ApprovalOutcome.REJECTED,
status = ApprovalStatus.COMPLETED,
tier = domainRequest.tier,
resolutionTimestamp = Clock.System.now(),
reason = decision.reason,
userSteering = decision.userSteering,
),
)
}
@@ -0,0 +1,252 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.context.model.ContextBucket
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
import com.correx.core.events.events.ArtifactRepairFailedEvent
import com.correx.core.events.events.ArtifactRepairResolvedEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.StageCheckpointPassedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.ToolDefinition
import com.correx.core.inference.ToolFunction
import com.correx.core.tools.contract.Tool
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
import com.correx.core.validation.artifact.ArtifactFailure
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import java.nio.file.Files
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
// --- per-run effective registry + policy ---
internal fun SessionOrchestrator.effectivesFor(config: OrchestrationConfig): RunEffectives {
val wsTools = config.workspace?.let { workspaceToolRegistryProvider?.forWorkspace(it) }
val registry = wsTools?.registry ?: toolRegistry
val executor = wsTools?.executor ?: toolExecutor
val policy = config.workspace
?.let { WorkspacePolicy(it.workspaceRoot, it.privilegedLocations) }
?: workspacePolicy
return RunEffectives(registry, executor, policy)
}
/**
* Handles an [ArtifactExtractionPipeline.ExtractionResult.Unresolved]: attempts one grammar-constrained
* LLM-repair rung (only for SCHEMA / MISSING_INFO — the classifier gates the expensive rung), then
* consults the policy [decide] to map a hard failure onto an orchestrator action. Records the §6 events.
*/
internal suspend fun SessionOrchestrator.repairArtifact(
sessionId: SessionId,
stageId: StageId,
slot: TypedArtifactSlot,
unresolved: ArtifactExtractionPipeline.ExtractionResult.Unresolved,
stageConfig: StageConfig,
effectives: RunEffectives,
timeoutMs: Long,
): ArtifactLadderOutcome {
val schema = slot.kind.deriveJsonSchema()
val eligible = unresolved.classification in setOf(ArtifactFailure.SCHEMA, ArtifactFailure.MISSING_INFO)
val bestCandidate = unresolved.bestCandidate
if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
val repaired = runArtifactRepairInference(
sessionId, stageId, slot, bestCandidate, stageConfig, effectives, timeoutMs,
)
val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
val json = reRun.canonicalJson.toString()
emitArtifactRepairResolved(sessionId, stageId, slot, json)
return ArtifactLadderOutcome.Text(json)
}
}
val decision = decide(unresolved.classification, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
emit(
sessionId,
ArtifactRepairFailedEvent(
artifactId = slot.name,
classification = unresolved.classification.name,
decision = decision::class.simpleName ?: "Unknown",
reason = unresolved.detail,
sessionId = sessionId,
stageId = stageId,
),
)
// ponytail: RetryProducer / RegenerateArtifactOnly / EscalateHuman all fold into a retryable
// failure — the existing retryStageOrFail loop re-runs the full producer (incl. the tools-less
// emission nudge) and surfaces exhaustion to the operator. A dedicated regenerate-only /
// approval-pause mid-artifact path is deferred until one is shown to help.
return ArtifactLadderOutcome.Reject(
StageExecutionResult.Failure(
"artifact repair failed (${unresolved.classification}): ${unresolved.detail}",
retryable = decision != ArtifactPolicyDecision.Abort,
),
)
}
/** One tools-less, grammar-constrained inference asking the model to repair its own malformed artifact. */
internal suspend fun SessionOrchestrator.runArtifactRepairInference(
sessionId: SessionId,
stageId: StageId,
slot: TypedArtifactSlot,
bestCandidate: String,
stageConfig: StageConfig,
effectives: RunEffectives,
timeoutMs: Long,
): String? {
val schema = slot.kind.deriveJsonSchema()
val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
val prompt = "The previous output for the '${slot.name.value}' artifact was malformed and did not " +
"match the required schema.\n\nMalformed output:\n$bestCandidate\n\nReturn ONLY a single JSON " +
"object matching this schema. Do not add fields, prose, or code fences.\nSchema: $schemaJson"
val entry = ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "artifactRepair",
sourceId = UUID.randomUUID().toString(),
content = prompt,
tokenEstimate = estimateTokens(prompt),
role = EntryRole.USER,
)
val ctx = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = listOf(entry),
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
val result = runInference(
sessionId, stageId, ctx, stageConfig, timeoutMs, ResponseFormat.Json(schema), effectives, withTools = false,
)
return (result as? InferenceResult.Success)?.response?.text
}
internal suspend fun SessionOrchestrator.emitArtifactRepairAttempted(
sessionId: SessionId,
stageId: StageId,
slot: TypedArtifactSlot,
classification: ArtifactFailure,
rung: String,
) = emit(
sessionId,
ArtifactRepairAttemptedEvent(slot.name, classification.name, rung, sessionId, stageId),
)
internal suspend fun SessionOrchestrator.emitArtifactRepairResolved(
sessionId: SessionId,
stageId: StageId,
slot: TypedArtifactSlot,
json: String,
) {
val hash = artifactStore.put(json.toByteArray())
emit(sessionId, ArtifactRepairResolvedEvent(slot.name, hash, sessionId, stageId))
}
internal fun SessionOrchestrator.stampBuckets(entries: List<ContextEntry>): List<ContextEntry> = entries.map {
if (it.sourceType in REQUIRED_SOURCE_TYPES) it.copy(bucket = ContextBucket.REQUIRED) else it
}
internal fun SessionOrchestrator.parseFileWrittenArtifact(json: String): FileWrittenArtifact? =
runCatching { Json.decodeFromString(FileWrittenArtifact.serializer(), json) }.getOrNull()
/**
* Projects the full change set of the stage(s) that produced a file_written artifact from
* recorded events: ArtifactContentStoredEvent → producing stageIds, ToolInvocationRequested →
* that stage's invocations, FileWrittenEvent → the paths actually written. Pure projection
* over existing events — no new artifact kind, no producer change, replay-safe. Null when no
* writes are on record (caller falls back to the cached single-file JSON).
*/
internal fun SessionOrchestrator.fileWrittenManifest(sessionId: SessionId, needed: ArtifactId): String? {
val events = eventStore.read(sessionId)
val stageIds = events.mapNotNull { it.payload as? ArtifactContentStoredEvent }
.filter { it.artifactId == needed }
.map { it.stageId }
.toSet()
if (stageIds.isEmpty()) return null
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId in stageIds }
.map { it.invocationId }
.toSet()
val paths = events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds && it.postImageHash != null }
.map { it.path }
.distinct()
if (paths.isEmpty()) return null
return buildString {
appendLine("Files written by the producing stage (use file_read to load any content you need):")
paths.forEach { appendLine("- $it") }
}.trimEnd()
}
internal suspend fun SessionOrchestrator.emitStageCheckpoint(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
) {
if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return
val expected = stageConfig.produces.map { it.name.value }.toSet()
if (expected.isEmpty()) return
val produced = eventStore.read(sessionId)
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value }
.toSet()
val cp = StageCheckpointReconciler.reconcile(expected, produced)
if (cp.passed) {
emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced))
} else {
emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing))
}
}
/**
* Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`),
* check every workspace-relative file path its brief named against the filesystem. Existence
* is a nondeterministic observation, so the result is recorded as a [BriefGroundingCheckedEvent]
* (invariant #9) — replay reads it back, never re-probes. A hallucinated path fails the stage
* (retryable), feeding the missing paths back so the model re-grounds on the next attempt.
*/
// emit_artifact gives the model a structured channel to produce its required artifact: it calls
// the tool with the fields filled in (parameter schema = the artifact's own schema) instead of
// emitting free-form JSON as a final message. Tool-calling models handle this far more reliably,
// and it sidesteps llama.cpp's grammar+tools incompatibility (the artifact can't be grammar-
// constrained while tools are present). Empty when the stage has no LLM-emitted slot.
internal fun SessionOrchestrator.emitArtifactTool(stageConfig: StageConfig): List<ToolDefinition> {
val slot = stageConfig.produces.firstOrNull { it.kind.llmEmitted } ?: return emptyList()
val schema = Json.encodeToJsonElement(JsonSchema.serializer(), slot.kind.deriveJsonSchema()).jsonObject
return listOf(
ToolDefinition(
function = ToolFunction(
name = EMIT_ARTIFACT_TOOL,
description = "Emit the required '${slot.name.value}' artifact by filling in the fields " +
"below. Prefer this over writing the JSON as a plain message.",
parameters = schema,
),
),
)
}
@@ -0,0 +1,412 @@
package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationQuestions
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.RepoKnowledgeHit
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ResponseFormat
import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException
/** Cache mapping "sessionId:artifactId" to JSON-serialized artifact payload content.
* Populated when ProcessResult artifacts are stored on tool failure.
* Used by DefaultSessionOrchestrator.step() to populate EvaluationContext.artifactContent. */
internal fun SessionOrchestrator.evictArtifactContentCache(sessionId: SessionId) {
val prefix = "${sessionId.value}:"
artifactContentCache.keys.removeAll { it.startsWith(prefix) }
}
/** Deterministic extraction/repair ladder for near-miss LLM artifact text (prose-wrapped JSON,
* code fences, trailing commas). Pure — recomputes on replay, records no events. */
internal suspend fun SessionOrchestrator.buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,
): List<ContextEntry> {
if (responseFormat !is ResponseFormat.Json) return emptyList()
val compactSchema = Json.encodeToString(JsonSchema.serializer(), responseFormat.schema)
val instruction = "Respond with a single JSON object matching this schema. " +
"Do not include markdown, code fences, or commentary outside the JSON. " +
"Schema: $compactSchema"
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = instruction,
sourceType = "schemaInstruction",
sourceId = stageId.value,
tokenEstimate = estimateTokens(instruction),
role = EntryRole.SYSTEM,
),
)
}
internal suspend fun SessionOrchestrator.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 = 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),
role = EntryRole.SYSTEM,
)
is ApprovalDecisionResolvedEvent -> {
val steering = p.userSteering
when {
steering != null -> ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
content = steering.text,
sourceType = "steeringNote",
sourceId = steering.sessionId.value,
tokenEstimate = estimateTokens(steering.text),
role = EntryRole.USER,
)
// A bare rejection (no note) still feeds back so the model does not
// re-propose the identical call on the retryable re-run (anti-loop).
p.outcome == ApprovalOutcome.REJECTED -> {
val feedback = "A previous tool call was rejected by the user. " +
"Do not repeat the same call; choose a different approach."
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
content = feedback,
sourceType = "rejectionFeedback",
sourceId = sessionId.value,
tokenEstimate = estimateTokens(feedback),
role = EntryRole.SYSTEM,
)
}
else -> null
}
}
else -> null
}
}
}
/**
* Injects the initial user intent (the freeform request that started the run) as a pinned L0
* SYSTEM entry present in EVERY stage's context (architecture-conformance, 2026-07-14). The intent
* is the single most load-bearing constraint of a run, yet it previously reached normal stages only
* as a repo-map retrieval seed (repoKnowledgeQuery) or, on the rare Tier-2 recovery path, the
* arbiter ticket — so an implementer could drift from the goal with the goal itself absent from its
* authoritative context. Standing at L0/SYSTEM it is weighted as an instruction and never dropped
* under budget. Absent for fixed-task workflows (no InitialIntentEvent) → empty.
*/
internal suspend fun SessionOrchestrator.buildIntentEntry(sessionId: SessionId): List<ContextEntry> {
val intent = initialIntent(sessionId)?.takeIf { it.isNotBlank() } ?: return emptyList()
val content = "## Authoritative intent\nThe operator's request that started this run. Everything " +
"you produce must serve it; do not drift from it or expand its scope:\n$intent"
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = content,
sourceType = "initialIntent",
sourceId = sessionId.value,
tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM,
),
)
}
/** The freeform request that started the run, or null for a fixed-task workflow. */
internal fun SessionOrchestrator.initialIntent(sessionId: SessionId): String? =
eventStore.read(sessionId)
.mapNotNull { it.payload as? InitialIntentEvent }
.firstOrNull()?.intent?.trim()
/**
* Injects the operator's answers to a stage's open questions as a pinned L0 SYSTEM entry, so the
* stage sees its own questions resolved on the clarification re-run. The prompt calls these answers
* "authoritative", so they are placed as authoritative standing instructions (L0/SYSTEM), not a
* droppable L2 USER turn — matching the mechanics to the stated authority. Correlates each answer's
* questionId back to the prompt recorded on the [ClarificationRequestedEvent].
*/
internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(sessionId: SessionId): List<ContextEntry> {
val events = eventStore.read(sessionId)
val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent }
if (answered.isEmpty()) return emptyList()
val prompts = events
.mapNotNull { it.payload as? ClarificationRequestedEvent }
.flatMap { it.questions }
.associate { it.id to it.prompt }
val rendered = answered.flatMap { it.answers }.joinToString("\n") { a ->
"Q: ${prompts[a.questionId] ?: a.questionId}\nA: ${a.value}"
}
val content = "The operator answered your open questions. Treat these answers as authoritative " +
"and do not ask them again:\n$rendered"
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = content,
sourceType = "clarificationAnswer",
sourceId = sessionId.value,
tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM,
),
)
}
/**
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
* run: record the questions (invariant #9 — observed at parse time), await the operator's
* answers, record them, and return true so the caller re-runs the stage with the answers in
* context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
* proceeds. Returns false when there are no questions or the round budget is spent.
*/
internal suspend fun SessionOrchestrator.requestClarificationIfNeeded(
sessionId: SessionId,
stageId: StageId,
graph: WorkflowGraph,
): Boolean {
val slot = graph.stages[stageId]?.produces?.firstOrNull { it.kind.llmEmitted } ?: return false
val content = artifactContentCache["${sessionId.value}:${slot.name.value}"] ?: return false
val questions = ClarificationQuestions.parse(content)
if (questions.isEmpty()) return false
val priorRounds = eventStore.read(sessionId)
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
if (priorRounds >= tuning.maxClarificationRounds) return false
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
pendingClarifications[requestId] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "CLARIFICATION_PENDING"))
emit(sessionId, ClarificationRequestedEvent(requestId, sessionId, stageId, questions))
return try {
val answers = deferred.await()
emit(sessionId, ClarificationAnsweredEvent(requestId, sessionId, stageId, answers))
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
true
} finally {
pendingClarifications.remove(requestId)
}
}
/**
* Renders the recorded repo map as a structural "what exists" listing — directories with
* their files, alphabetical, no recency ranking and no symbol excerpts. Relevance is the
* retrieval layer's job (buildRelevantFilesEntry); the map only tells the model what it can
* `file_read`. Markdown/docs paths are gated: they only appear when the stage prompt
* explicitly asks about documentation, so churny kernel docs can't bury real source
* (2026-07-05 context-poisoning root cause). Reads the latest [RepoMapComputedEvent] from
* the log (replay-safe — never re-scans the FS).
*/
internal suspend fun SessionOrchestrator.buildRepoMapEntries(sessionId: SessionId, stagePrompt: String = ""): List<ContextEntry> {
val map = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoMapComputedEvent }
.lastOrNull() ?: return emptyList()
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
val paths = map.entries.map { it.path }.distinct()
.filter { docsWanted || !isDocPath(it) }
.sorted()
if (paths.isEmpty()) return emptyList()
val byDir = paths.groupBy { it.substringBeforeLast('/', missingDelimiterValue = ".") }
val content = buildString {
appendLine("## Repo layout (what exists — use file_read for content)")
byDir.entries.sortedBy { it.key }.forEach { (dir, files) ->
val names = files.map { it.substringAfterLast('/') }
val shown = names.take(tuning.repoMapFilesPerDir)
val more = names.size - shown.size
val suffix = if (more > 0) ", …(+$more more)" else ""
appendLine("- $dir/ (${names.size}): ${shown.joinToString(", ")}$suffix")
}
}.trimEnd()
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L3,
content = content,
sourceType = "repoMap",
sourceId = "repo-map",
tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM,
),
)
}
/**
* True when [stageId]'s own prompt must be suppressed because the stage was entered to repair a
* gate failure. That happens when the most recent transition INTO this stage was a ticket route
* ([TICKET_ROUTE], same entry check as DefaultSessionOrchestrator.ticketReturnMove) — the failure
* ticket routes an owner stage back to fix a file it authored, and its original generative mandate
* ("scaffold the project") is exactly what drives it to overwrite that file with a stub. The
* recovery-ticket entry ([buildRecoveryTicketEntry]) becomes the sole mandate instead.
*
* The purpose-built recovery arbiter (role=recovery) is excluded: it is also ticket-entered but
* its own prompt IS a tuned repair prompt, so it keeps it. Replay-safe (recorded events only).
*/
internal suspend fun SessionOrchestrator.buildContextualRepoEntries(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): List<ContextEntry> {
val stagePrompt = repoKnowledgeQuery(sessionId, stageId, stageConfig)
val recorded = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoKnowledgeRetrievedEvent }
.lastOrNull { it.stageId == stageId }
if (recorded != null) {
return repoEntriesOrMapFloor(sessionId, recorded.hits, stagePrompt) + buildDocsCatalogEntry(sessionId)
}
val retriever = repoKnowledgeRetriever
?: return buildRepoMapEntries(sessionId, stagePrompt) + buildDocsCatalogEntry(sessionId)
val hits = runCatching { retriever.retrieve(sessionId, stagePrompt, tuning.repoMapInjectTopK) }
.getOrElse { e ->
if (e is CancellationException) throw e
log.warn("repo-knowledge retrieval failed for stage {}: {}", stageId.value, e.message)
emptyList()
}
// Record the raw retrieval for audit (a degenerate all-zero result is itself the signal that
// the embedder is noop / the index is empty), but only useful hits ever reach the agent.
if (hits.isNotEmpty()) emit(sessionId, RepoKnowledgeRetrievedEvent(sessionId, stageId, stagePrompt, hits))
return repoEntriesOrMapFloor(sessionId, hits, stagePrompt) + buildDocsCatalogEntry(sessionId)
}
/**
* Always-on, capped catalog of the docs that exist, as `path — descriptor` lines (the descriptor
* is the doc's frontmatter/H1/first-line summary recorded on [RepoMapEntry.symbols]). This is the
* read-on-demand doc index: one line per doc lets a stage decide whether to file_read it, so docs
* stay discoverable without the heading-flood the docs gate was added to suppress. Distinct from
* [buildRepoMapEntries]/[repoEntriesOrMapFloor], which still exclude doc *content* from the source
* layout for non-doc prompts. Reads the recorded map (replay-safe — never re-scans the FS).
*/
internal suspend fun SessionOrchestrator.buildDocsCatalogEntry(sessionId: SessionId): List<ContextEntry> {
val map = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoMapComputedEvent }
.lastOrNull() ?: return emptyList()
val docs = map.entries
.filter { isDocPath(it.path) }
.sortedByDescending { it.score }
.take(tuning.docsCatalogMax)
if (docs.isEmpty()) return emptyList()
val content = buildString {
appendLine("## Docs available (file_read to open — do not assume contents)")
docs.forEach { entry ->
val descriptor = entry.symbols.firstOrNull()?.takeIf { it.isNotBlank() }
appendLine(if (descriptor != null) "- ${entry.path}$descriptor" else "- ${entry.path}")
}
}.trimEnd()
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L3,
content = content,
sourceType = "docsCatalog",
sourceId = "docs-catalog",
tokenEstimate = estimateTokens(content),
role = EntryRole.SYSTEM,
),
)
}
/**
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
*
* Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
* embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
* above real source for a code task, re-poisoning the context the floor's gate was added to fix
* (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
*/
internal suspend fun SessionOrchestrator.repoEntriesOrMapFloor(
sessionId: SessionId,
hits: List<RepoKnowledgeHit>,
stagePrompt: String,
): List<ContextEntry> {
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
return if (useful.isEmpty()) {
buildRepoMapEntries(sessionId, stagePrompt)
} else {
listOf(buildRelevantFilesEntry(useful))
}
}
/**
* Stamps the required/optional budget bucket by sourceType. REQUIRED = the stage cannot run
* correctly without it (task, constraints, loaded inputs, failure feedback) — never trimmed,
* build fails fast if it alone exceeds the budget. Everything else (repo map, retrieval,
* journal, profiles, vocabulary, transcript) is OPTIONAL and shares the remainder under
* per-type ceilings.
*/
internal suspend fun SessionOrchestrator.buildNeedsArtifactEntries(
sessionId: SessionId,
stageConfig: StageConfig,
criticIds: Set<ArtifactId> = emptySet(),
criticFromStage: String? = null,
): List<ContextEntry> {
return stageConfig.needs.mapNotNull { needed ->
val cached = artifactContentCache["${sessionId.value}:${needed.value}"]
?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val content = if (needed in criticIds) {
"## Critic feedback from stage '${criticFromStage ?: "reviewer"}': ${needed.value}\n" +
"This is reviewer feedback on your previous attempt. " +
"Address every finding before completing the stage.\n$cached"
} else {
// file_written handoff: hand the next stage the whole producing stage's change set
// as a manifest (known files), not one file's content. The cache value only holds
// the LAST write of the stage, so rendering it inline both misleads (5 of 6 files
// invisible) and wastes budget — content stays lazy behind file_read.
val manifest = parseFileWrittenArtifact(cached)
?.let { fileWrittenManifest(sessionId, needed) }
"## Input artifact: ${needed.value}\n${manifest ?: cached}"
}
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = content,
sourceType = if (needed in criticIds) "criticFeedback" else "neededArtifact",
sourceId = needed.value,
tokenEstimate = estimateTokens(content),
role = EntryRole.USER,
)
}
}
@@ -0,0 +1,593 @@
package com.correx.core.kernel.orchestration
import com.correx.core.context.builder.RequiredContextOverflowException
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.FinishReason
import com.correx.core.inference.ResponseFormat
import com.correx.core.sessions.Session
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
import com.correx.core.validation.artifact.ArtifactFailure
import com.correx.core.validation.model.ValidationContext
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException
@Suppress("CyclomaticComplexMethod")
internal suspend fun SessionOrchestrator.executeStage(
sessionId: SessionId,
stageId: StageId,
graph: WorkflowGraph,
session: Session,
config: OrchestrationConfig,
effectives: RunEffectives,
): StageExecutionResult {
val stageConfig = requireNotNull(graph.stages[stageId]) {
"Stage '${stageId.value}' not declared in workflow graph"
}
val systemPrompt = stageConfig.metadata["systemPrompt"]
?.let { path ->
runCatching { promptResolver.resolve(path) }
.onFailure {
log.error(error(stageId, path, it))
}
.getOrNull()
}
?.takeIf { it.isNotBlank() }
?.let { text ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = text,
sourceType = "systemPrompt",
sourceId = stageId.value,
tokenEstimate = estimateTokens(text),
role = EntryRole.SYSTEM,
),
)
} ?: config.defaultSystemPromptPath
?.let { path ->
runCatching { promptResolver.resolve(path) }
.onFailure {
log.error(error(stageId, path, it))
}
.getOrNull()
}
?.takeIf { it.isNotBlank() }
?.let { text ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = text,
sourceType = "systemPrompt",
sourceId = stageId.value,
tokenEstimate = estimateTokens(text),
role = EntryRole.SYSTEM,
),
)
} ?: emptyList()
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
// "make the smallest change, don't rewrite from scratch" plus the gate evidence. See
// mandateSuppressedByTicket.
val promptEntries = if (mandateSuppressedByTicket(sessionId, stageId, stageConfig)) {
emptyList()
} else {
stageConfig.metadata["promptInline"]
?.takeIf { it.isNotBlank() }
?.let { text ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = text,
sourceType = "agentPrompt",
sourceId = stageId.value,
tokenEstimate = estimateTokens(text),
role = EntryRole.USER,
),
)
}
?: stageConfig.metadata["prompt"]
?.let { path ->
val resolvedText = runCatching { promptResolver.resolve(path) }
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${it.message}",
)
}
.getOrNull()
val text = resolvedText?.takeIf { it.isNotBlank() }
?: error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"declared prompt '$path' could not be resolved",
)
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L1,
content = text,
sourceType = "agentPrompt",
sourceId = stageId.value,
tokenEstimate = estimateTokens(text),
role = EntryRole.USER,
),
)
}
?: emptyList()
}
val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
require(llmEmittedSlots.size <= 1) {
"Stage '${stageId.value}' declares ${llmEmittedSlots.size} LLM-emitted artifact kinds; " +
"only 0 or 1 is supported"
}
val responseFormat = llmEmittedSlots.firstOrNull()
?.let { ResponseFormat.Json(it.kind.deriveJsonSchema()) }
?: ResponseFormat.Text
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
val intentEntries = buildIntentEntry(sessionId)
val steeringEntries = buildSteeringNoteEntries(sessionId)
val clarificationEntries = buildClarificationAnswerEntries(sessionId)
// Maintain the running transcript in true chronological order. Reading it back
// from currentContext.layers (a Map grouped by layer) would scramble turn order
// across rounds; instead we grow our own ordered list and let the builder restamp
// ordinals from it each round.
val journalState = decisionJournalRepository.getJournal(sessionId)
val summaryText = journalState.summaryArtifactId?.let { id ->
artifactStore.get(id)?.toString(Charsets.UTF_8)
?: run {
log.warn("Journal summary artifact {} not found in store — rendering without summary", id.value)
null
}
}
val journalText = decisionJournalRenderer.render(journalState, summaryText)
val journalEntries = if (journalText.isBlank()) emptyList() else listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = journalText,
sourceType = "decisionJournal",
sourceId = "decision-journal",
tokenEstimate = estimateTokens(journalText),
role = EntryRole.SYSTEM,
),
)
val repoMapEntries = buildContextualRepoEntries(sessionId, stageId, stageConfig)
val sessionEvents = eventStore.read(sessionId)
val criticIds = criticArtifactIds(sessionEvents, graph, stageId)
val criticFrom = sessionEvents
.mapNotNull { it.payload as? RefinementIterationEvent }
.lastOrNull { it.cycleKey.endsWith("->${stageId.value}") }
?.cycleKey?.substringBefore("->")
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig, criticIds, criticFrom)
val profileEntries = session.state.boundProfile?.about
?.takeIf { it.isNotBlank() }
?.let { about ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = "## Operator profile\n$about",
sourceType = "operatorProfile",
sourceId = "operator-profile",
tokenEstimate = about.length / 4,
role = EntryRole.SYSTEM,
),
)
} ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
// carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
// rely on memory, re-read before editing" — which drives a small model into an endless
// read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
// to the stage's subproject, not the repo root; not read-mandating) is deferred.
val agentInstructionsEntries = emptyList<ContextEntry>()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val recoveryTicketEntries = buildRecoveryTicketEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
// Deterministic claim: a stage flagged claimTask gets the kernel to claim the next ready task
// (or re-surface the one already claimed) and inject its context bundle as L0 — the loop
// advances by recorded fact, not by the agent remembering to claim.
val claimedTaskEntries = if (stageConfig.metadata["claimTask"] == "true") {
taskClaimCoordinator?.claimNext(sessionId)?.let { bundle ->
listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = bundle,
sourceType = "claimedTask",
sourceId = stageId.value,
tokenEstimate = estimateTokens(bundle),
role = EntryRole.SYSTEM,
),
)
} ?: emptyList()
} else {
emptyList()
}
// Remaining-delta checklist (stage-termination design 2026-07-11 §1a): the stage's currently
// unmet file-contract assertions as a forward-looking, shrinking TODO. Injected pinned
// (neverDrop) so it survives the budget/dedup passes that otherwise wipe the model's memory of
// progress; refreshed inside the tool loop each time a write lands (§1b, cache-until-write).
var remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
val remainingDeltaEntries = remainingDeltaResults
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
?.let { listOf(it) } ?: emptyList()
var accumulatedEntries = stampBuckets(
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries + claimedTaskEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
)
val contextPack = runCatching {
contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = accumulatedEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
}.getOrElse { e ->
if (e is CancellationException) throw e
if (e !is RequiredContextOverflowException) throw e
// Required context doesn't fit — retrying with the same inputs can't succeed.
log.error("[Orchestrator] stage={}: {}", stageId.value, e.message)
return StageExecutionResult.Failure(e.message ?: "required context overflow", retryable = false)
}
emitContextTruncationIfNeeded(sessionId, stageId, contextPack)
var currentContext = contextPack
var inferenceResult = runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
)
var toolRounds = 0
var consecutiveReadOnlyRounds = 0
var consecutiveRejectedRounds = 0
// Fingerprints ("tool:arguments") of every read-only call made during the current
// no-write streak. A round only counts against the read-loop breaker if it repeats calls
// already seen — a stage reading N distinct files before writing is legitimate context
// gathering, not a loop, and shouldn't trip the same counter as re-reading the same file.
val seenReadFingerprints = mutableSetOf<String>()
// Set when the model produces its artifact via the emit_artifact tool instead of a final
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
var llmArtifactOverride: String? = null
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
fun owesFileWrite(): Boolean = fileWrittenSlots.isNotEmpty() &&
fileWrittenSlots.any { artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank() }
// Gate: if the stage declares require_task_decompose, block stage_complete until at least one
// task_decompose (or task_create) call has completed successfully in this stage. Checked via
// ToolInvocationRequestedEvent + ToolExecutionCompletedEvent in the session event log.
// ponytail: scans events on every stage_complete attempt; negligible cost (in-memory list, rare call).
fun owesTaskDecompose(): Boolean {
if (stageConfig.metadata["requireTaskDecompose"] != "true") return false
val events = eventStore.read(sessionId)
val requestedIds = events
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId && it.toolName in setOf("task_decompose", "task_create") }
.map { it.invocationId }
.toSet()
if (requestedIds.isEmpty()) return true
val completedIds = events
.mapNotNull { it.payload as? ToolExecutionCompletedEvent }
.filter { it.toolName in setOf("task_decompose", "task_create") && it.invocationId in requestedIds }
.map { it.invocationId }
.toSet()
return completedIds.isEmpty()
}
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
accumulatedEntries = accumulatedEntries + ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = UUID.randomUUID().toString(),
content = nudge,
tokenEstimate = estimateTokens(nudge),
role = EntryRole.TOOL,
)
currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = accumulatedEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
toolRounds++
return runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
forceWriteOnly = forceWriteOnly,
)
}
val writeNudge = "ERROR: you described the change but did not apply it. You MUST call the " +
"file_edit or file_write tool to write the change to the file before finishing — do " +
"not output the file contents as a message."
val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
"nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
"this stage. Do not call file_read or list_dir again before you have written a file."
while (
inferenceResult is InferenceResult.Success &&
toolRounds < tuning.maxToolRounds &&
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
) {
// Content turn (no tool call) but the stage still owes a file_written artifact: the
// model emitted the change as prose instead of invoking the write tool. Nudge it to
// actually write (F-018).
if (inferenceResult.response.finishReason !is FinishReason.ToolCall) {
inferenceResult = pushBack(writeNudge)
continue
}
// emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
// Capture those arguments as the artifact content and exit the loop straight to validation
// (the post-loop capture honours this override instead of the empty assistant text).
val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
llmArtifactOverride = emitCall.function.arguments
break
}
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
// Skip tool dispatch — the loop exits and normal validation/transition follows
// (emitToolArtifacts + verifyProduces run on the success path after the loop).
if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) {
// Premature-completion guard: don't accept stage_complete while the stage still
// owes an artifact. For an LLM-emitted slot, nudge to emit the JSON (F-010); for a
// file_written slot, nudge to invoke the write tool (F-018). Otherwise complete.
val owedLlm = llmEmittedSlots.firstOrNull()?.takeIf {
inferenceResult.response.text.isBlank() &&
artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank()
}
when {
owedLlm != null -> inferenceResult = pushBack(
"ERROR: stage_complete is not allowed yet — you have not emitted the " +
"required '${owedLlm.name.value}' artifact. Call the emit_artifact tool with " +
"the fields filled in (or respond with the artifact as a single JSON message " +
"matching the provided schema).",
)
owesFileWrite() -> inferenceResult = pushBack(writeNudge)
owesTaskDecompose() -> inferenceResult = pushBack(
"ERROR: stage_complete is not allowed yet — this stage requires a successful " +
"task_decompose (or task_create) call before it can complete. Call task_decompose " +
"with a parent epic and DEPENDS_ON-linked children representing the full task graph. " +
"Do not call stage_complete until task_decompose has returned successfully.",
)
else -> break
}
continue
}
val toolEntries = dispatchToolCalls(sessionId, stageId,
inferenceResult.response.toolCalls, stageConfig, effectives,
approvalModeFor(session.state.boundProfile?.approvalMode),
inferenceResult.response.reasoning)
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
if (fatalEntry != null) {
emitProcessResultEvents(sessionId, stageId, stageConfig)
return StageExecutionResult.Failure(
fatalEntry.content.removePrefix("FATAL: "),
retryable = false,
)
}
// Recoverable (ERROR:) tool failures are NOT terminal — they flow back into the
// loop as tool-result context so the model can see the error and adapt (bounded by
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
accumulatedEntries = accumulatedEntries + toolEntries
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
// an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
// nudge; a real write resets the counter so multi-file stages are unaffected.
if (owesFileWrite() &&
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
) {
val roundFingerprints = inferenceResult.response.toolCalls
.map { "${it.function.name}:${it.function.arguments}" }
val madeProgress = roundFingerprints.any { it !in seenReadFingerprints }
seenReadFingerprints += roundFingerprints
if (madeProgress) {
consecutiveReadOnlyRounds = 0
} else {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= tuning.readLoopNudgeThreshold) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
}
}
} else {
consecutiveReadOnlyRounds = 0
seenReadFingerprints.clear()
}
// Rejection-loop breaker (stage-type agnostic): every tool result this round was a
// rejection (plane-2 BLOCKED or a stage/policy ERROR) and nothing succeeded. A stage that
// owes no file_written artifact (discovery, analyst) never trips the read-loop breaker, so
// without this it re-issues the same rejected call until MAX_TOOL_ROUNDS. After the
// threshold, tell it to stop retrying and produce its output; any success resets.
val toolResults = toolEntries.filter { it.role == EntryRole.TOOL }
val allRejected = toolResults.isNotEmpty() &&
toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") }
if (allRejected) {
consecutiveRejectedRounds++
if (consecutiveRejectedRounds >= tuning.rejectionLoopNudgeThreshold) {
consecutiveRejectedRounds = 0
inferenceResult = pushBack(
"STOP. Your last tool calls were all rejected and retrying the same paths will " +
"keep failing. Do not repeat them. Proceed with the information you already " +
"have: produce this stage's required output now (call stage_complete, or emit " +
"the required artifact). If a path you wanted does not exist yet, that is a " +
"fact to record — do not keep probing for it.",
)
continue
}
} else {
consecutiveRejectedRounds = 0
}
// Refresh the remaining-delta checklist only when a write actually landed this round
// (cache-until-write, §1b): reads can never change the contract verdict, so re-evaluating
// on them would just burn filesystem work and re-emit identical events. On a successful
// write, recompute contract-minus-reality and swap the pinned entry so the model watches
// the list shrink toward the empty=done signal it previously lacked.
val wroteThisRound = inferenceResult.response.toolCalls.any { it.function.name in WRITE_TOOL_NAMES } &&
toolEntries.any {
it.role == EntryRole.TOOL &&
!it.content.startsWith("ERROR:") && !it.content.startsWith("BLOCKED:")
}
if (wroteThisRound) {
remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
listOfNotNull(refreshed)
}
currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = accumulatedEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
inferenceResult = runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
)
toolRounds++
}
// Final clean emission. Producing the artifact while tools are in the request is unreliable on
// some local chat templates (Gemma leaks `<|channel>` markers into the text; other models keep
// tool-calling until the round cap and never emit). For an LLM-emitted stage that hasn't already
// produced its artifact via the emit_artifact tool, fire ONE tools-less inference — verified to
// yield clean JSON deterministically. Skipped when the model already emitted clean final text.
if (inferenceResult is InferenceResult.Success && llmEmittedSlots.isNotEmpty() && llmArtifactOverride == null) {
val last = inferenceResult.response
val needsCleanEmission = last.finishReason is FinishReason.ToolCall ||
last.text.isBlank() || last.text.contains("<|channel")
if (needsCleanEmission && !isCancelled(sessionId)) {
val nudge = "Stop calling tools. Output the required '${llmEmittedSlots.first().name.value}' " +
"artifact now as a single JSON object matching the schema — no tool calls, no commentary."
accumulatedEntries = accumulatedEntries + ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = UUID.randomUUID().toString(),
content = nudge,
tokenEstimate = estimateTokens(nudge),
role = EntryRole.TOOL,
)
currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = stageId,
entries = accumulatedEntries,
budget = TokenBudget(limit = stageConfig.tokenBudget),
)
inferenceResult = runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs,
responseFormat, effectives, withTools = false,
)
}
}
return when (inferenceResult) {
is InferenceResult.Cancelled -> StageExecutionResult.Failure("CANCELLED", retryable = false)
is InferenceResult.Failed ->
StageExecutionResult.Failure(inferenceResult.reason, retryable = isRetryableFailure(inferenceResult.reason))
is InferenceResult.Success -> {
if (isCancelled(sessionId)) return StageExecutionResult.Failure("CANCELLED", retryable = false)
// Capture the LLM-emitted artifact's JSON so transition conditions
// (e.g. ArtifactFieldEquals on a reviewer's verdict) can read it. An emit_artifact
// tool call (llmArtifactOverride) supplies the content directly; otherwise the
// artifact is the final assistant message text.
val rawArtifactText = llmArtifactOverride ?: inferenceResult.response.text
val slot = llmEmittedSlots.firstOrNull()
// Artifact-emission ladder: deterministic repair (prose/fences/trailing commas), then
// — rarely, gated by the failure classifier — one grammar-constrained LLM-repair rung,
// before the text is cached/validated. On a hard failure the policy decision
// short-circuits the stage. Only the nondeterministic rung records events (spec 2026-07-04 §6).
val artifactText = if (slot != null) {
when (val res = artifactExtractionPipeline.run(rawArtifactText, slot.kind.deriveJsonSchema())) {
is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
if (res.repaired) {
emitArtifactRepairAttempted(sessionId, stageId, slot, ArtifactFailure.FORMATTING, "DETERMINISTIC")
emitArtifactRepairResolved(sessionId, stageId, slot, res.canonicalJson.toString())
}
res.canonicalJson.toString()
}
is ArtifactExtractionPipeline.ExtractionResult.Unresolved ->
when (val ladder = repairArtifact(sessionId, stageId, slot, res, stageConfig, effectives, config.stageTimeoutMs)) {
is ArtifactLadderOutcome.Text -> ladder.text
is ArtifactLadderOutcome.Reject -> return ladder.failure
}
}
} else {
rawArtifactText
}
val artifactHash = if (artifactText != rawArtifactText || llmArtifactOverride != null) {
artifactStore.put(artifactText.toByteArray())
} else {
inferenceResult.responseArtifactId
}
slot?.let { slot ->
artifactContentCache["${sessionId.value}:${slot.name.value}"] = artifactText
artifactHash?.let { hash ->
emit(
sessionId,
ArtifactContentStoredEvent(
artifactId = slot.name,
contentHash = hash,
sessionId = sessionId,
stageId = stageId,
),
)
}
}
val validationCtx = ValidationContext(
graph = graph,
sessionState = session.state,
availableTools = effectives.registry?.all()?.map { it.name }?.toSet(),
producedArtifactContent = graph.stages.values.flatMap { it.produces }.mapNotNull { slot ->
artifactContentCache["${sessionId.value}:${slot.name.value}"]?.let { slot.name.value to it }
}.toMap(),
)
when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
is StageExecutionResult.Success -> {
emitToolArtifacts(sessionId, stageId, stageConfig)
emitLlmArtifacts(sessionId, stageId, stageConfig)
runPostStageGates(
sessionId, stageId, stageConfig, effectives,
session.state.boundProjectProfile?.commands ?: emptyMap(),
)
}
is StageExecutionResult.Failure -> outcome
}
}
}
}
@@ -0,0 +1,387 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.artifacts.kind.KindInference
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.GroundingReference
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.StageCheckpointFailedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.StageConfig
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal fun SessionOrchestrator.verifyProduces(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
if (stageConfig.produces.isEmpty()) {
if (stageConfig.allowedTools.isEmpty()) return StageExecutionResult.Success(emptyList())
val ranTool = eventStore.read(sessionId).any {
(it.payload as? ToolInvocationRequestedEvent)?.stageId == stageId
}
if (ranTool) return StageExecutionResult.Success(emptyList())
log.error(
"[Orchestrator] stage declared no artifacts and ran no tools " +
"session=${sessionId.value} stage=${stageId.value}",
)
return StageExecutionResult.Failure(
"stage ${stageId.value} declared no artifacts and ran no tools",
retryable = true,
gate = "produces",
)
}
val producedIds = stageConfig.produces.map { it.name }.toSet()
val present = eventStore.read(sessionId)
.mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId }
.toSet()
val missing = producedIds - present
if (missing.isEmpty()) return StageExecutionResult.Success(emptyList())
val missingIds = missing.joinToString(", ") { it.value }
log.error(
"[Orchestrator] stage missing produced artifacts " +
"session=${sessionId.value} stage=${stageId.value} missing=$missingIds",
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not produce declared artifacts: $missingIds",
retryable = true,
gate = "produces",
)
}
/**
* Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable
* by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against
* the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that
* holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned
* by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that
* halt and surfaces *why* the plan diverged.
*/
internal suspend fun SessionOrchestrator.groundBriefReferences(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
if (stageConfig.metadata["groundReferences"] != "true") return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
val artifact = stageConfig.produces
.filter { it.kind.llmEmitted }
.firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] }
?: return StageExecutionResult.Success(emptyList())
val references = BriefReferenceExtractor.fileReferences(artifact)
if (references.isEmpty()) return StageExecutionResult.Success(emptyList())
val results = references.map { ref ->
GroundingReference(ref, worldProbe.exists(workspaceRoot.resolve(ref)))
}
emit(sessionId, BriefGroundingCheckedEvent(sessionId, stageId, stageId.value, results))
val missing = results.filterNot { it.grounded }.map { it.reference }
return if (missing.isEmpty()) {
StageExecutionResult.Success(emptyList())
} else {
log.warn(
"[Orchestrator] brief references nonexistent paths session={} stage={} missing={}",
sessionId.value, stageId.value, missing.joinToString(", "),
)
StageExecutionResult.Failure(
"stage ${stageId.value} references paths that do not exist in the workspace: " +
"${missing.joinToString(", ")}. Reference only files you have read.",
retryable = true,
gate = "brief_grounding",
)
}
}
/**
* Brief echo gate (plan-pipeline-addenda §A1): for a stage that opted in (`brief_echo`),
* diff the model's restatement artifact against the original brief. Both are already in the
* artifact content cache (recorded via ArtifactCreatedEvent), so the diff is a pure function
* of recorded data — deterministically recomputable on replay without re-probing any
* environment (invariants #8, #9). Emits [BriefEchoMismatchEvent] on mismatch as a domain
* signal, then fails the stage (retryable) so the pipeline cannot reach plan generation on a
* misread brief.
*
* Brief-source selection among `needs`: prefers the artifact named "analysis"; if absent,
* falls back to the single needs artifact present. When there are multiple needs and none is
* named "analysis", the gate skips (ambiguous brief) and returns Success.
*/
internal suspend fun SessionOrchestrator.checkBriefEcho(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList())
val echoSlot = stageConfig.produces.firstOrNull { it.kind.llmEmitted }
?: return StageExecutionResult.Success(emptyList())
val echo = artifactContentCache["${sessionId.value}:${echoSlot.name.value}"]
?: return StageExecutionResult.Success(emptyList())
val brief = when {
stageConfig.needs.any { it.value == "analysis" } ->
artifactContentCache["${sessionId.value}:analysis"]
stageConfig.needs.size == 1 ->
artifactContentCache["${sessionId.value}:${stageConfig.needs.single().value}"]
else -> null // ambiguous — skip
} ?: return StageExecutionResult.Success(emptyList())
val div = BriefEchoDiff.compare(brief, echo)
return if (div.isMismatch) {
emit(
sessionId,
BriefEchoMismatchEvent(
sessionId = sessionId,
stageId = stageId,
uncoveredRequirements = div.uncoveredRequirements,
hallucinatedFiles = div.hallucinatedFiles,
hallucinatedSymbols = div.hallucinatedSymbols,
),
)
log.warn(
"[Orchestrator] brief echo mismatch session={} stage={} uncovered={} hallucinatedFiles={}",
sessionId.value, stageId.value,
div.uncoveredRequirements.joinToString(", "),
div.hallucinatedFiles.joinToString(", "),
)
StageExecutionResult.Failure(
"stage ${stageId.value} restated the brief unfaithfully — " +
"dropped requirements: [${div.uncoveredRequirements.joinToString("; ")}]; " +
"files not in the brief: [${div.hallucinatedFiles.joinToString(", ")}]. " +
"Restate the brief exactly; do not drop requirements or invent files.",
retryable = true,
gate = "brief_echo",
)
} else {
StageExecutionResult.Success(emptyList())
}
}
/**
* Post-validation stage gates, run in order; the first failure short-circuits and is returned.
* Cheap deterministic checks run first (produces presence, brief grounding, brief echo), then
* the heavier process-spawning static-analysis step last, so it only runs once a stage is
* otherwise sound.
*/
internal suspend fun SessionOrchestrator.runPostStageGates(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val produced = verifyProduces(sessionId, stageId, stageConfig)
if (produced is StageExecutionResult.Failure) return produced
// Stage-level plan checkpoint (C-A2): once a stage has produced its declared artifacts,
// record the checkpoint before the heavier brief/static gates run.
emitStageCheckpoint(sessionId, stageId, stageConfig)
val gates: List<suspend () -> StageExecutionResult> = listOf(
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
)
for (gate in gates) {
val result = gate()
if (result is StageExecutionResult.Failure) return result
}
return StageExecutionResult.Success(emptyList())
}
/**
* Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
* produces file_written artifacts, project the files it actually wrote (Option B: runtime
* manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
* (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
* verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
* and never re-inspects the filesystem.
*
* A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
* back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
* skipped here; the static-analysis command gate is their authoritative check.
*/
/**
* Evaluate the stage's file contract against current filesystem reality and record the verdict
* in a [ContractGateEvaluatedEvent] (invariant #9). Returns the per-assertion results, or null
* when the gate does not apply (no evaluator, no workspace root, no file_written produces, or no
* paths). Shared by the post-stage [runContractGate] and the within-loop remaining-delta refresh
* ([buildRemainingDeltaEntry]) so both read the same contract-minus-reality computation from a
* single evaluation + single event emission.
*/
internal suspend fun SessionOrchestrator.evaluateStageContract(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): List<ContractAssertionResult>? {
val evaluator = contractAssertionEvaluator ?: return null
val workspaceRoot = effectives.policy?.workspaceRoot ?: return null
if (stageConfig.produces.none { it.kind.id == "file_written" }) return null
// Option A B: the files the stage actually wrote (runtime manifest) unioned with the
// concrete files the plan declared it would produce (expectedFiles). A declared file that
// was never written survives here and fails file_exists — that is the missing-file catch.
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
if (paths.isEmpty()) return null
val assertions = paths.flatMap { path ->
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
}
val results = assertions.map { a ->
val v = evaluator.evaluate(workspaceRoot, a)
ContractAssertionResult(
assertionId = a.id,
target = a.target,
layer = a.layer.name,
evaluator = a.evaluator.name,
passed = v.passed,
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
)
}
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
return results
}
/** The currently-failing assertions as (target, assertionId, evidence) triples for the checklist. */
internal fun SessionOrchestrator.contractFailureItems(results: List<ContractAssertionResult>): List<Triple<String, String, String>> =
results.filterNot { it.passed }.map { Triple(it.target, it.assertionId, it.evidence) }
internal suspend fun SessionOrchestrator.runContractGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
val results = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
?: return StageExecutionResult.Success(emptyList())
val failures = results.filterNot { it.passed }
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] contract gate failed session={} stage={} assertions={}",
sessionId.value, stageId.value,
failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
)
val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
return StageExecutionResult.Failure(
"stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
retryable = true,
gate = "contract",
)
}
/**
* Plan-compile gate: for a stage that produces an `execution_plan` artifact (the freestyle
* architect), compile its produced plan JSON as a post-stage gate. A compile failure fails the
* stage retryably with the compiler error fed back, so the existing retry-feedback loop hands it
* to the architect and the model self-corrects — rather than the post-planning compiler rejecting
* a broken plan with no re-plan path (which parked the session in ACTIVE forever). The verdict is
* recorded in a [PlanCompileCheckedEvent] (invariant #9) so replay reads it back and never
* re-compiles.
*/
internal suspend fun SessionOrchestrator.runPlanCompileGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
): StageExecutionResult {
val check = planCompilationCheck ?: return StageExecutionResult.Success(emptyList())
val planSlot = stageConfig.produces.firstOrNull { it.kind.id == "execution_plan" }
?: return StageExecutionResult.Success(emptyList())
val json = artifactContentCache["${sessionId.value}:${planSlot.name.value}"]
?: return StageExecutionResult.Success(emptyList())
val error = check.check(json, "freestyle-${sessionId.value}")
emit(sessionId, PlanCompileCheckedEvent(sessionId, stageId, passed = error == null, error = error))
if (error == null) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] plan-compile gate failed session={} stage={}: {}",
sessionId.value, stageId.value, error,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} produced an execution_plan that does not compile. " +
"Fix the plan before it can be locked:\n$error",
retryable = true,
gate = "plan_compile",
)
}
/**
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
* under them. Pure projection, replay-safe.
*/
/**
* True if the session has committed to a buildable project — it wrote either a real code module
* (a path whose inferred kind carries the `imports_resolve` COMPILER assertion: TS/React/Kotlin
* source) OR a build manifest ([BUILD_MANIFEST_KINDS]: package.json, a Gradle module) that
* declares a toolchain. Read from the recorded FileWritten manifest (invariant #9: replay-safe,
* no filesystem access), it is the run-time signal that warrants the auto build gate — the
* planner's declared kinds (uniformly `file_written`) never reveal this, but the written paths do.
*
* The manifest case is what catches the degenerate scaffold that writes only `package.json` and
* no sources: `npm run build` then fails for lack of an entry, so the gate rejects the empty
* "project" instead of letting it reach COMPLETED. A docs-only plan writes neither, so it is left
* alone.
*/
internal fun SessionOrchestrator.sessionProducedBuildTarget(sessionId: SessionId): Boolean =
eventStore.read(sessionId)
.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.postImageHash != null }
.map { it.path }
.distinct()
.any { path ->
val kind = KindInference.kindFor(path) ?: return@any false
kind in BUILD_MANIFEST_KINDS ||
KindContractTable.assertionsFor(kind, path).any { it.id == "imports_resolve" }
}
internal fun SessionOrchestrator.stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
val events = eventStore.read(sessionId)
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.map { it.invocationId }
.toSet()
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds && it.postImageHash != null }
.map { it.path }
.distinct()
}
/**
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
* workspace root. Every result is recorded in a [StaticAnalysisCompletedEvent] — an environment
* observation captured at run time (invariant #9), so replay reads it back and never re-runs.
*
* A non-clean command fails the stage retryably with its output fed back verbatim (§2: compiler/
* test output is ground truth, not LLM noise), so the producing stage fixes the issue and only
* static-clean output ever reaches the downstream LLM reviewer — its context thereby excludes
* anything static tools already catch, with no parsing or context plumbing.
*/
@@ -0,0 +1,285 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.StaticAnalysisFinding
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.BuildExpectation
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.validation.model.ValidationReport
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal suspend fun SessionOrchestrator.runStaticAnalysis(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
if (stageConfig.staticAnalysis.isEmpty()) return StageExecutionResult.Success(emptyList())
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) {
log.warn(
"[Orchestrator] stage declares static_analysis but no {} is wired — skipping " +
"session={} stage={}",
if (runner == null) "runner" else "workspace root",
sessionId.value, stageId.value,
)
return StageExecutionResult.Success(emptyList())
}
val results = stageConfig.staticAnalysis.map { command ->
val run = runner.run(workspaceRoot, command)
StaticAnalysisFinding(
command = command,
exitCode = run.exitCode,
summary = run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP),
) to run.output
}
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, results.map { it.first }))
val failures = results.filterNot { it.first.clean }
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] static analysis failed session={} stage={} commands={}",
sessionId.value, stageId.value,
failures.joinToString(", ") { it.first.command },
)
val detail = failures.joinToString("\n\n") { (finding, output) ->
"$ ${finding.command} (exit ${finding.exitCode})\n${output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP)}"
}
return StageExecutionResult.Failure(
"stage ${stageId.value} did not pass static analysis. Fix these before review:\n\n$detail",
retryable = true,
gate = "static_analysis",
)
}
/**
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
* command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
* project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
* degrades safely rather than failing when the operator hasn't configured commands. A non-clean
* run fails the stage retryably with the build output fed back (recorded as a
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
*/
internal suspend fun SessionOrchestrator.runExecutionGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
// An explicit build_expectation resolves directly. Otherwise, if the compiler flagged this
// (terminal) stage as auto-gateable and the session actually wrote a code module, promote to a
// PROJECT build — the LLM planner never sets build_expectation on its file_written stages, so
// this is the only path that gates a scaffold's compilability. Code-ness is judged here (not at
// compile time) from the real FileWritten manifest, so a docs-only plan is left alone.
val expectation: BuildExpectation? = when {
stageConfig.buildExpectation != BuildExpectation.NONE -> stageConfig.buildExpectation
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
else -> null
}
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val command = profileCommands[alias]
if (command.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage {} needs a {} build gate but project profile has no '{}' " +
"command — skipping execution gate",
stageId.value, expectation, alias,
)
return StageExecutionResult.Success(emptyList())
}
runSetupCommand(sessionId, stageId, workspaceRoot, profileCommands, runner)?.let { return it }
val run = runner.run(workspaceRoot, command)
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
if (finding.clean) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
sessionId.value, stageId.value, expectation, command,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not pass its $expectation build gate. " +
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
retryable = true,
gate = "execution",
)
}
/**
* Run the operator-declared `setup` command (profile alias [SETUP_COMMAND_ALIAS]) before a build
* gate, when one is configured. A build like `tsc && vite build` fails spuriously if the project
* was never prepared (deps uninstalled, codegen not run) — the funnel reads that as a code failure
* and routes into futile repair. The operator names the fix in their profile (`setup = "npm ci"`,
* `"cargo fetch"`, `"./gradlew dependencies"`, …); the kernel stays ecosystem-agnostic. Runs in the
* workspace root, recorded as a [StaticAnalysisCompletedEvent] (invariant #9). Returns a
* [StageExecutionResult.Failure] when setup itself fails (a real, actionable error), else null (no
* setup configured, or it succeeded — proceed to the build).
*/
internal suspend fun SessionOrchestrator.runSetupCommand(
sessionId: SessionId,
stageId: StageId,
workspaceRoot: Path,
profileCommands: Map<String, String>,
runner: StaticAnalysisRunner,
): StageExecutionResult? {
val setup = profileCommands[SETUP_COMMAND_ALIAS]?.takeIf { it.isNotBlank() } ?: return null
log.info(
"[Orchestrator] running setup command before build gate session={} stage={} command='{}'",
sessionId.value, stageId.value, setup,
)
val run = runner.run(workspaceRoot, setup)
val finding = StaticAnalysisFinding(setup, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
if (finding.clean) return null
return StageExecutionResult.Failure(
"stage ${stageId.value} setup command failed before its build gate. " +
"Fix these before proceeding:\n\n$ $setup (exit ${run.exitCode})\n" +
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
retryable = true,
gate = "execution",
)
}
/**
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
* (the FileWritten manifest — never blind context) and reviews them against the stage objective,
* returning PR-comment-style findings. Runs last, so its input is static-clean (the deterministic
* funnel already passed) and its findings exclude anything the cheap gates catch.
*
* The reviewer is advisory: every finding is recorded in a [ReviewFindingsRaisedEvent] and
* surfaces to the operator regardless of verdict. A [ReviewVerdict.FAIL] blocks the stage
* retryably ONLY when it carries a high-confidence *correctness* finding and the review-block
* budget ([REVIEW_BLOCK_RETRY_CAP]) is not yet spent — so a stuck reviewer can never trap the
* stage; after the budget, findings surface without blocking. Suboptimal/opinion findings never
* block. Recorded as an environment observation (invariant #9); replay never re-runs the reviewer.
*/
internal suspend fun SessionOrchestrator.runReviewGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
if (!stageConfig.semanticReview) return StageExecutionResult.Success(emptyList())
val reviewer = semanticReviewer ?: return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
val files = stageWrittenPaths(sessionId, stageId)
if (files.isEmpty()) return StageExecutionResult.Success(emptyList())
// Goal-conformance framing (architecture-conformance 2026-07-14): lead the reviewer with the
// authoritative intent and the stage's declared scope so it can judge whether the files serve
// the goal (and stay within scope), not just whether they are internally defect-free — the
// deterministic gates already cover mechanical correctness. Absent intent/scope degrade to the
// prior upstream-artifact objective.
val intentHeader = initialIntent(sessionId)?.takeIf { it.isNotBlank() }
?.let { "## Authoritative intent (the goal these files must serve)\n$it\n\n" } ?: ""
val scopeHeader = stageConfig.touches.takeIf { it.isNotEmpty() }
?.let { "## This stage's declared scope\n${it.joinToString(", ")}\n\n" } ?: ""
val objective = (
intentHeader + scopeHeader +
stageConfig.needs
.mapNotNull { artifactContentCache["${sessionId.value}:${it.value}"] }
.joinToString("\n\n")
.ifBlank { "Stage '${stageId.value}' produced the files below; review them for defects." }
).take(REVIEW_OBJECTIVE_CAP)
val outcome = reviewer.review(sessionId, stageId, workspaceRoot, files, objective)
// A FAIL blocks only on a high-confidence correctness finding, and only while the review-block
// budget remains — count prior review blocks for this stage from the log (replay-safe).
val priorBlocks = eventStore.read(sessionId)
.mapNotNull { it.payload as? ReviewFindingsRaisedEvent }
.count { it.stageId == stageId && it.blocked }
val blockingFinding = outcome.findings.firstOrNull {
it.correctness && it.confidence >= tuning.reviewBlockMinConfidence
}
val shouldBlock = outcome.verdict == ReviewVerdict.FAIL &&
blockingFinding != null &&
priorBlocks < tuning.reviewBlockRetryCap
emit(sessionId, ReviewFindingsRaisedEvent(sessionId, stageId, outcome.verdict, outcome.findings, blocked = shouldBlock))
if (!shouldBlock) {
if (outcome.findings.isNotEmpty()) {
log.info(
"[Orchestrator] semantic review advisory session={} stage={} verdict={} findings={}",
sessionId.value, stageId.value, outcome.verdict, outcome.findings.size,
)
}
return StageExecutionResult.Success(emptyList())
}
log.warn(
"[Orchestrator] semantic review blocked session={} stage={} finding={}",
sessionId.value, stageId.value, blockingFinding?.target,
)
val detail = outcome.findings
.filter { it.correctness }
.joinToString("\n") { "- [${it.severity}] ${it.target}: ${it.message}" +
(it.suggestedFix?.let { fix -> "\n fix: $fix" } ?: "") }
return StageExecutionResult.Failure(
"stage ${stageId.value} failed semantic review — fix these correctness issues:\n$detail",
retryable = true,
gate = "review",
)
}
internal suspend fun SessionOrchestrator.emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
) {
// Emit artifact lifecycle events for process_result slots on failure branches.
// Content was already stored in CAS + cache by dispatchToolCalls for every outcome.
stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot ->
emitAll(
sessionId,
listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
}
}
// Collapse a validation report into a concise, actionable reason. This string becomes the
// RetryAttemptedEvent.failureReason that the retry-feedback entry feeds back to the model, so a
// weak model is told *what* was wrong (e.g. "required property 'summary' missing") instead of a
// bare "validation failed" it can't act on. Capped to keep the feedback prompt small.
internal fun SessionOrchestrator.summarizeRejection(report: ValidationReport): String {
val errors = report.sections.flatMap { section ->
section.issues
.filter { it.severity == ValidationSeverity.ERROR }
.map { it.message }
}.distinct()
return if (errors.isEmpty()) {
"validation failed"
} else {
"validation failed: " + errors.take(tuning.maxFeedbackIssues).joinToString("; ")
}
}
@@ -0,0 +1,292 @@
package com.correx.core.kernel.orchestration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
/**
* Compute a unified-diff preview for file-affecting tool calls.
* For [file_write] with operation "write": reads the existing file (if any) and diffs it
* against the proposed content from the LLM arguments.
* For all other tools: returns null (caller falls back to raw JSON args).
*
* This function performs blocking I/O (file reads) and must be called from a suspend context
* that will dispatch it on [Dispatchers.IO].
*/
internal suspend fun computeToolPreview(
toolName: String,
parameters: Map<String, Any>,
workspaceRoot: java.nio.file.Path?,
): String? {
if (toolName == "shell") return shellCommandPreview(parameters)
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
if (toolName == "file_edit") return computeFileEditPreview(parameters, workspaceRoot)
if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: return null
// file_write no longer carries an `operation` param (delete was split into file_delete), so the
// preview is unconditional: path + content is always a write. The old `operation == "write"` gate
// silently bailed to the raw-JSON args fallback for every file_write.
val proposedContent = parameters["content"] as? String ?: return null
val existingContent = readFileIfExists(path, workspaceRoot)
return buildDiffString(path, existingContent, proposedContent)
}
internal suspend fun readFileIfExists(path: String, workspaceRoot: java.nio.file.Path?): String? =
withContext(Dispatchers.IO) {
runCatching {
// Resolve relative paths against the session's workspace root, same as the tools do —
// resolving against the daemon CWD showed the operator the wrong file (or nothing) when
// server CWD ≠ workspace_root.
val raw = java.nio.file.Paths.get(path)
val filePath = if (raw.isAbsolute || workspaceRoot == null) raw else workspaceRoot.resolve(raw)
if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath)
} else null
}.getOrNull()
}
/**
* Preview for `file_edit`'s "replace" and "append" operations: reads the existing file and
* simulates the edit locally to produce a diff, mirroring `file_write`'s preview. "patch" is left
* to the raw-JSON-args fallback — simulating `patch -p1` application here isn't worth the risk of
* the preview silently disagreeing with what the tool actually does.
*/
internal suspend fun computeFileEditPreview(
parameters: Map<String, Any>,
workspaceRoot: java.nio.file.Path?,
): String? {
val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String ?: return null
val existingContent = readFileIfExists(path, workspaceRoot) ?: return null
val proposedContent = when (operation) {
"append" -> existingContent + (parameters["content"] as? String ?: return null)
"replace" -> {
val target = parameters["target"] as? String ?: return null
val replacement = parameters["replacement"] as? String ?: return null
if (!existingContent.contains(target)) return null
existingContent.replaceFirst(target, replacement)
}
else -> return null
}
return buildDiffString(path, existingContent, proposedContent)
}
/**
* Render a shell tool's argv as a full, shell-quoted command line for the approval
* card (tui-requirements §5 / §3 "full untruncated command"). Returning the rendered
* line — rather than the raw `arguments.take(200)` fallback — keeps the command both
* untruncated and human-readable; the TUI's deterministic parser re-tokenizes it.
*/
internal fun shellCommandPreview(parameters: Map<String, Any>): String? {
val argv = when (val raw = parameters["argv"]) {
is List<*> -> raw.filterIsInstance<String>()
is String -> runCatching { Json.decodeFromString<List<String>>(raw) }.getOrElse { emptyList() }
else -> emptyList()
}
if (argv.isEmpty()) return null
return argv.joinToString(" ") { shellQuoteToken(it) }
}
/** Single-quote a token when it carries whitespace or shell metacharacters. */
internal fun shellQuoteToken(token: String): String {
if (token.isEmpty()) return "''"
val needsQuote = token.any { it.isWhitespace() || it in "\"'\\|&;<>(){}\$`*?[]~#!" }
return if (needsQuote) "'" + token.replace("'", "'\\''") + "'" else token
}
internal enum class DiffOpKind { EQUAL, DELETE, INSERT }
internal data class DiffOp(val kind: DiffOpKind, val line: String)
/**
* Build a unified-diff string for a full file replacement, using a line-level LCS diff
* so a small edit to a large file shows as a tight hunk rather than a full-file replacement.
* When the file doesn't exist yet (new file), only `+` lines are shown.
*/
internal fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
val existingLines = existingContent?.lines() ?: emptyList()
val proposedLines = proposedContent.lines()
if (existingLines == proposedLines) return " (no change)"
val ops = diffLines(existingLines, proposedLines)
return buildString {
appendLine("--- a/$path")
appendLine("+++ b/$path")
append(renderHunks(ops))
}.trimEnd()
}
/** Line-level LCS diff producing an edit script of EQUAL/DELETE/INSERT ops. */
internal fun diffLines(oldLines: List<String>, newLines: List<String>): List<DiffOp> {
val n = oldLines.size
val m = newLines.size
val lcs = Array(n + 1) { IntArray(m + 1) }
for (i in n - 1 downTo 0) {
for (j in m - 1 downTo 0) {
lcs[i][j] = if (oldLines[i] == newLines[j]) {
lcs[i + 1][j + 1] + 1
} else {
maxOf(lcs[i + 1][j], lcs[i][j + 1])
}
}
}
val ops = mutableListOf<DiffOp>()
var i = 0
var j = 0
while (i < n && j < m) {
when {
oldLines[i] == newLines[j] -> {
ops += DiffOp(DiffOpKind.EQUAL, oldLines[i])
i++
j++
}
lcs[i + 1][j] >= lcs[i][j + 1] -> {
ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
i++
}
else -> {
ops += DiffOp(DiffOpKind.INSERT, newLines[j])
j++
}
}
}
while (i < n) {
ops += DiffOp(DiffOpKind.DELETE, oldLines[i])
i++
}
while (j < m) {
ops += DiffOp(DiffOpKind.INSERT, newLines[j])
j++
}
return ops
}
/** Groups an edit script into unified-diff hunks with [context] lines of surrounding EQUAL context. */
internal fun renderHunks(ops: List<DiffOp>, context: Int = 3): String {
val changedIndices = ops.indices.filter { ops[it].kind != DiffOpKind.EQUAL }
if (changedIndices.isEmpty()) return ""
data class Hunk(val start: Int, val end: Int)
val hunks = mutableListOf<Hunk>()
var hunkStart = changedIndices.first()
var hunkEnd = changedIndices.first()
for (idx in changedIndices.drop(1)) {
if (idx - hunkEnd <= context * 2) {
hunkEnd = idx
} else {
hunks += Hunk(hunkStart, hunkEnd)
hunkStart = idx
hunkEnd = idx
}
}
hunks += Hunk(hunkStart, hunkEnd)
var oldLine = 1
var newLine = 1
var opIdx = 0
return buildString {
for (hunk in hunks) {
val from = maxOf(0, hunk.start - context)
val to = minOf(ops.size - 1, hunk.end + context)
while (opIdx < from) {
if (ops[opIdx].kind != DiffOpKind.INSERT) oldLine++
if (ops[opIdx].kind != DiffOpKind.DELETE) newLine++
opIdx++
}
val oldStart = oldLine
val newStart = newLine
var oldCount = 0
var newCount = 0
val lines = StringBuilder()
for (k in from..to) {
val op = ops[k]
when (op.kind) {
DiffOpKind.EQUAL -> {
lines.append(' ').append(op.line).append('\n')
oldCount++
newCount++
}
DiffOpKind.DELETE -> {
lines.append('-').append(op.line).append('\n')
oldCount++
}
DiffOpKind.INSERT -> {
lines.append('+').append(op.line).append('\n')
newCount++
}
}
}
appendLine("@@ -$oldStart,${oldCount.coerceAtLeast(1)} +$newStart,${newCount.coerceAtLeast(1)} @@")
append(lines)
oldLine = oldStart + oldCount
newLine = newStart + newCount
opIdx = to + 1
}
}
}
/**
* The candidate in [paths] that shares [guessed]'s filename and the most trailing path segments,
* or null when nothing shares the filename. Lets a wrong package/dir prefix
* (`com/correx/server/routes/SessionRoutes.kt`) still resolve to the real file
* (`.../com/correx/apps/server/routes/SessionRoutes.kt`) without ever inventing a non-existent path.
*/
internal fun closestPathByFilename(guessed: String, paths: List<String>): String? {
val guessedName = guessed.substringAfterLast('/')
if (guessedName.isBlank()) return null
val guessedSegs = guessed.split('/').filter { it.isNotBlank() }
return paths.asSequence()
.filter { it.substringAfterLast('/') == guessedName }
.maxByOrNull { candidate ->
val segs = candidate.split('/').filter { it.isNotBlank() }
var shared = 0
var i = segs.size - 1
var j = guessedSegs.size - 1
while (i >= 0 && j >= 0 && segs[i] == guessedSegs[j]) {
shared++; i--; j--
}
shared
}
}
/**
* Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered
* child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic
* fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are
* re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments.
*/
internal fun renderDecomposePreview(parameters: Map<String, Any>): String? {
val project = parameters["project"] as? String ?: return null
val tasks = (parameters["tasks"] as? String)
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null
if (tasks.isEmpty()) return null
val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content }
val refToIndex = tasks.mapIndexedNotNull { i, e ->
((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i }
}.toMap()
val parentTitle = (parameters["parent"] as? String)
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject }
?.let { (it["title"] as? JsonPrimitive)?.content }
return buildString {
append("Decompose '").append(project).append("' into:")
parentTitle?.let { append("\n epic: ").append(it) }
tasks.forEachIndexed { i, e ->
val o = e as? JsonObject
append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)")
val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList())
.map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d }
if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")")
}
}
}
@@ -0,0 +1,91 @@
package com.correx.core.kernel.orchestration
import com.correx.core.context.model.ContextPack
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.graph.StageConfig
import kotlinx.datetime.Clock
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal fun SessionOrchestrator.error(
stageId: StageId,
path: String,
throwable: Throwable,
): String = "[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${throwable.message}"
/**
* Strip an outer markdown code fence (```` ```json `` / ``` ``` ````) when the whole response
* is a single fenced block. Conservative: only acts when the trimmed text both starts and ends
* with a fence, so prose responses that merely contain an inline block are left untouched.
*/
internal fun SessionOrchestrator.resolveStagePromptText(stageId: StageId, stageConfig: StageConfig): String {
stageConfig.metadata["promptInline"]?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
stageConfig.metadata["prompt"]?.let { path ->
runCatching { promptResolver.resolve(path) }.getOrNull()
?.trim()?.takeIf { it.isNotBlank() }
?.let { return it }
}
return stageId.value
}
/**
* The role's own prompt text (see [resolveStagePromptText]) is identical across every session
* that runs the stage — it carries zero session-specific signal, so similarity against it can
* only ever reflect generic lexical overlap with the role's boilerplate ("file_read", "ls",
* "grep"...), never what the user actually asked for. The one thing that IS session-specific
* is the initial user intent, so it's prepended here as the real anchor for the query (2026-07-08
* finding: without it, the query matched a tool script's own symbol names over the files the
* user's request actually named).
*/
internal fun SessionOrchestrator.repoKnowledgeQuery(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): String {
val intent = eventStore.read(sessionId)
.mapNotNull { it.payload as? InitialIntentEvent }
.firstOrNull()?.intent?.trim()
val stagePrompt = resolveStagePromptText(stageId, stageConfig)
return if (intent.isNullOrBlank()) stagePrompt else "$intent\n\n$stagePrompt"
}
// --- event emission ---
// Surface context-budget overflow as an event on the workflow path. The builder already
// enforces the budget (drops oldest/compressible entries, pinning steering + event history);
// this records that it happened so replay and operators can see it, mirroring the router path.
internal suspend fun SessionOrchestrator.emitContextTruncationIfNeeded(
sessionId: SessionId,
stageId: StageId,
contextPack: ContextPack,
) {
val meta = contextPack.compressionMetadata
if (meta.entriesDropped <= 0) return
emit(
sessionId,
ContextTruncatedEvent(
sessionId = sessionId,
turnId = stageId.value,
entriesDropped = meta.entriesDropped,
truncatedLayers = meta.truncatedLayers.map { it.name },
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
}
internal fun SessionOrchestrator.fallbackTokenEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1)
}
// --- stage-entry approval gate ---
/**
* Emits [OrchestrationPausedEvent] + [ApprovalRequestedEvent] for a stage flagged
* `requiresApproval`, awaits the user decision, and records it.
* Returns `true` if the run should proceed into the stage, `false` if the approval was rejected.
*/
@@ -0,0 +1,732 @@
package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.approvals.Tier
import com.correx.core.approvals.isAtMost
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact
import com.correx.core.artifacts.kind.TypedArtifactSlot
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskSummary
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.toAssessedIssues
import com.correx.core.toolintent.toRiskSummary
import com.correx.core.sessions.projections.EgressAllowlistProjection
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.inference.ToolCallRequest
import com.correx.core.sessions.ApprovalMode
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolResult
import com.correx.core.transitions.graph.StageConfig
import kotlinx.coroutines.CompletableDeferred
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal fun SessionOrchestrator.stripCodeFence(text: String): String {
val trimmed = text.trim()
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
val withoutClose = trimmed.removeSuffix("```").trimEnd()
val firstNewline = withoutClose.indexOf('\n')
if (firstNewline < 0) return text
// Drop the opening fence line (``` optionally followed by a language tag).
return withoutClose.substring(firstNewline + 1).trim()
}
// On a recoverable tool failure, append the tool's argument schema to the error fed back to the
// model so it can self-correct its next attempt (it already sees its own malformed call in the
// assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
// NOTE: the "corrected arguments" phrasing was removed because it actively drives loops: a model
// that called list_dir frontend/ and got "directory not found" interprets "re-issue with corrected
// arguments" as "retry the same call" rather than "try a different path" — the arguments were
// valid, the path simply doesn't exist. The schema reference stays so malformed calls can still
// self-correct; the model must infer the tactic from the tool's error text.
internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String =
tool?.let {
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
}.orEmpty()
/**
* Render a tool result into the consistently-framed, bounded text the model sees. Success output
* is run through the tool's [ToolOutputCompressor], then framed with a uniform `[tool exit=N]`
* header. If it still exceeds [TOOL_RESULT_MAX_CHARS] the FULL raw output is spilled to the
* artifact store (CAS — durable, its hash recorded on the ToolReceipt in the event log) and only a
* head+tail preview is shown, with a marker telling the model to call `tool_output(ref=…)` for the
* rest. Failures keep their `ERROR:`/`FATAL:` sentinels unchanged — the all-rejected loop breaker
* keys on those prefixes.
*/
internal suspend fun SessionOrchestrator.renderToolResult(toolName: String, tool: Tool?, result: ToolResult): RenderedToolResult =
when (result) {
is ToolResult.Failure -> RenderedToolResult(
if (!result.recoverable) "FATAL: ${result.reason}" else "ERROR: ${result.reason}${toolArgsHint(tool)}",
null,
)
is ToolResult.Success -> {
val compressed = tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
?: result.output
val header = "[$toolName exit=${result.exitCode}]"
if (compressed.length <= TOOL_RESULT_MAX_CHARS) {
RenderedToolResult("$header\n$compressed", null)
} else {
// Spill the full RAW output (most complete) so retrieval returns everything, not the
// already-compressed preview the model saw.
val ref = artifactStore.put(result.output.toByteArray()).value
RenderedToolResult(frameTruncatedToolResult(header, compressed, ref), ref)
}
}
}
internal suspend fun SessionOrchestrator.dispatchToolCalls(
sessionId: SessionId,
stageId: StageId,
toolCalls: List<ToolCallRequest>,
stageConfig: StageConfig,
effectives: RunEffectives,
approvalMode: ApprovalMode,
// The reasoning that preceded this whole batch of tool calls (they all came from one
// inference response). Attached only to the first call's assistant entry — one thought
// block per model turn, matching how the response actually arrived.
reasoning: String? = null,
): List<ContextEntry> {
val executor = effectives.executor ?: return emptyList()
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
return toolCalls.withIndex().flatMap { (toolCallIndex, toolCall) ->
// Only the first call's assistant entry carries the reasoning — the whole batch came
// from one model turn/one thought block, so later calls in the same batch get none.
val toolCallReasoning = reasoning.takeIf { toolCallIndex == 0 }
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
val parameters = parseToolArguments(toolCall.function.arguments)
val request = ToolRequest(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolCall.function.name,
parameters = parameters,
)
val tool = effectives.registry?.resolve(toolCall.function.name)
val tier = tool?.tier ?: Tier.T2
// Out-of-workspace read access: a read whose target lexically resolves outside the
// workspace root. The intent plane raises PROMPT_USER for it; once the operator approves
// (recorded as OutsidePathAccessGrantedEvent), the same path this session skips the prompt
// and the tool jail is widened for it. Writes never take this path (isRead gate).
val isRead = tool?.requiredCapabilities?.contains(ToolCapability.FILE_READ) == true &&
tool.requiredCapabilities.contains(ToolCapability.FILE_WRITE) != true
val outsideReadTarget = if (isRead) {
outsideWorkspaceTarget(parameters, effectives.policy?.workspaceRoot)
} else {
null
}
val grantedOutside = grantedOutsidePaths(sessionId)
val alreadyGranted = outsideReadTarget != null && outsideReadTarget.toString() in grantedOutside
emit(
sessionId,
ToolInvocationRequestedEvent(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolCall.function.name,
tier = tier,
request = request,
capabilities = tool?.requiredCapabilities ?: emptySet(),
),
)
val deniedByStage = toolCall.function.name != STAGE_COMPLETE_TOOL &&
toolCall.function.name !in stageConfig.effectiveAllowedTools
val deniedByReadOnly = !deniedByStage && isReadOnlyMode(sessionId) &&
(tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true)
if (deniedByStage || deniedByReadOnly) {
val denyReason = if (deniedByReadOnly) {
"read-before-write: '${toolCall.function.name}' requires reading the file first — use file_read"
} else {
"tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
}
emit(
sessionId,
ToolExecutionRejectedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = denyReason,
),
)
val sourceId = toolCall.id ?: invocationId.value
return@flatMap listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = "ERROR: $denyReason",
tokenEstimate = estimateTokens(denyReason),
role = EntryRole.TOOL,
),
)
}
// Per-task write scope: while a task is claimed, narrow the manifest to its affected
// paths (recorded on the task) so the implementer can't write outside its unit of work.
// Falls back to the stage's static manifest when nothing is claimed.
val effectiveManifest = taskClaimCoordinator?.activeScope(sessionId)?.takeIf { it.isNotEmpty() }
?: stageConfig.writeManifest
val plane2Risk: RiskSummary? = runPlane2Assessment(
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
effectiveManifest,
)?.let { assessment ->
if (assessment.recommendedAction == RiskAction.BLOCK) {
val rationale = assessment.rationale.joinToString("; ")
// On a bad-path block, point the model at the closest real file so it fixes the
// path instead of retrying the same wrong guess (deep package paths are easy to
// misremember). Only fires when we can name a concrete match from the repo map.
val suggestion = if (assessment.rationale.any { it.contains(REFERENCE_EXISTS_CODE) }) {
(parameters["path"] as? String)
?.let { suggestClosestPath(sessionId, it) }
?.let { " Did you mean: $it ?" }
.orEmpty()
} else {
""
}
emit(
sessionId,
ToolExecutionRejectedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
// Record the specific rule rationale, not a generic string, so the audit
// trail (and task history) shows why a call was blocked.
reason = rationale.ifBlank { "blocked by tool-call policy" },
),
)
val sourceId = toolCall.id ?: invocationId.value
return@flatMap listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = "BLOCKED: $rationale$suggestion",
tokenEstimate = estimateTokens(rationale + suggestion),
role = EntryRole.TOOL,
),
)
}
assessment
}
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
// A steering note attached to a human approval is captured here and injected after the
// tool result, so the same-stage loop re-infers with it and the model acts on the note.
var approvalNote: String? = null
if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted) {
// no approval needed — either within the auto-approve tier, or this out-of-workspace
// read path was already approved earlier this session (this-path-this-session).
} else {
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
// workspace root so PROJECT grants match later sessions on the same repo.
val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values
val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
val activeGrants = (sessionGrants + ledgerGrants).toList()
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
val approvalCtx = ApprovalContext(
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
mode = approvalMode,
)
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val toolPreview = computeToolPreview(
toolCall.function.name, parameters, effectives.policy?.workspaceRoot,
)
val domainRequest = DomainApprovalRequest(
id = requestId,
tier = tier,
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = null,
timestamp = Clock.System.now(),
toolName = toolCall.function.name,
preview = toolPreview ?: toolCall.function.arguments.take(200),
)
val engineDecision = approvalEngine.evaluate(
domainRequest, approvalCtx, activeGrants, Clock.System.now(),
)
if (engineDecision.state == ApprovalStatus.COMPLETED) {
emitDecisionResolved(sessionId, domainRequest, engineDecision)
if (!engineDecision.isApproved) {
val rejectReason = engineDecision.reason ?: "denied"
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
emit(sessionId, ToolExecutionRejectedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = rejectReason,
))
val sourceId = toolCall.id ?: invocationId.value
return@flatMap listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = "ERROR: $rejectReason",
tokenEstimate = estimateTokens(rejectReason),
role = EntryRole.TOOL,
),
)
}
// grant auto-approved — fall through to execute
} else {
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[requestId] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
ApprovalRequestedEvent(
requestId = requestId,
tier = tier,
validationReportId = domainRequest.validationReportId,
riskSummaryId = null,
riskSummary = plane2Risk,
sessionId = sessionId,
stageId = stageId,
projectId = null,
toolName = toolCall.function.name,
preview = toolPreview ?: toolCall.function.arguments.take(200),
),
)
val userDecision = try {
deferred.await()
} finally {
pendingApprovals.remove(requestId)
}
emitDecisionResolved(sessionId, domainRequest, userDecision)
if (!userDecision.isApproved) {
val rejectReason = userDecision.reason ?: "approval denied"
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
emit(sessionId, ToolExecutionRejectedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = rejectReason,
))
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
val sourceId = toolCall.id ?: invocationId.value
return@flatMap listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = "ERROR: $rejectReason",
tokenEstimate = estimateTokens(rejectReason),
role = EntryRole.TOOL,
),
)
}
approvalNote = userDecision.userSteering?.text
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
}
}
// Reaching here means the call is authorized (all deny/block/pending paths returned early).
// For a first-time out-of-workspace read, record the grant so future reads of the same path
// this session skip the prompt (invariants #8/#9 — replay reads the recorded fact). Then
// widen the executed request's jail with every path approved outside the workspace.
if (outsideReadTarget != null && !alreadyGranted) {
emit(
sessionId,
OutsidePathAccessGrantedEvent(
sessionId = sessionId,
stageId = stageId,
path = outsideReadTarget.toString(),
),
)
}
val executedRequest = outsideReadTarget
?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
?: request
val result = executor.execute(executedRequest)
// Frame + bound the result once; on truncation this spills the full output to CAS and
// returns its hash, which we record on the receipt so the event log points at the full text.
val rendered = renderToolResult(toolCall.function.name, tool, result)
// Store ProcessResult artifact for every shell execution outcome
for (slot in processResultSlots) {
val processResult = when (result) {
is ToolResult.Success -> ProcessResultArtifact(
status = "success",
command = toolCall.function.arguments,
exitCode = result.exitCode,
stdout = result.output,
stderr = result.metadata["stderr"] ?: "",
durationMs = 0,
)
is ToolResult.Failure -> ProcessResultArtifact(
status = "failed",
command = toolCall.function.arguments,
exitCode = -1,
stdout = "",
stderr = result.reason,
durationMs = 0,
)
}
val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult)
val storedHash = artifactStore.put(jsonContent.toByteArray())
artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent
emit(
sessionId,
ArtifactContentStoredEvent(
artifactId = slot.name,
contentHash = storedHash,
sessionId = sessionId,
stageId = stageId,
),
)
}
// Record the tool execution outcome as events (invariant #5: every side effect is
// captured; silent execution is not allowed). For file-mutating tools, materialise a
// real file_written artifact from the actual on-disk result — so a stage that did NOT
// write cannot have its file_written slot rubber-stamped as validated (F-015), and the
// artifact carries the diff as evidence of the change.
recordToolExecution(
sessionId, stageId, toolCall, invocationId, tier, result,
tool as? FileAffectingTool, request, fileWrittenSlots, rendered.fullOutputHash,
)
val sourceId = toolCall.id ?: invocationId.value
val assistantEntry = ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
reasoning = toolCallReasoning,
)
val resultEntry = ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = rendered.content,
tokenEstimate = estimateTokens(rendered.content),
role = EntryRole.TOOL,
)
val steeringEntry = approvalNote?.takeIf { it.isNotBlank() }?.let {
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "steeringNote",
sourceId = sourceId,
content = "User steering on the approved '${toolCall.function.name}' call: $it",
tokenEstimate = estimateTokens(it),
role = EntryRole.USER,
)
}
listOfNotNull(assistantEntry, resultEntry, steeringEntry)
}
}
/**
* A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's
* scope — block the task so the loop advances instead of the implementer retrying the same
* out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected.
*/
internal suspend fun SessionOrchestrator.blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) {
if (toolName == SCOPE_PROPOSAL_TOOL) {
taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason")
}
}
internal suspend fun SessionOrchestrator.runPlane2Assessment(
sessionId: SessionId,
stageId: StageId,
invocationId: ToolInvocationId,
toolName: String,
request: ToolRequest,
tool: Tool?,
effectives: RunEffectives,
writeManifest: List<String>,
): RiskSummary? {
val assessor = toolCallAssessor ?: return null
val policy = effectives.policy ?: return null
// Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents
// through the projection. Unioned with the static allow-list in NetworkHostRule; empty when
// nothing has been granted, which never narrows the static allow-list.
val sessionEgressHosts = resolveSessionEgressHosts(sessionId)
val assessment = assessor.assess(
ToolCallAssessmentInput(
request = request,
capabilities = tool?.requiredCapabilities ?: emptySet(),
workspace = policy,
probe = worldProbe,
paramRoles = tool?.paramRoles ?: emptyMap(),
writeManifest = writeManifest,
sessionEgressHosts = sessionEgressHosts,
session = resolveSessionContext(sessionId),
),
)
emit(
sessionId,
ToolCallAssessedEvent(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
issues = assessment.toAssessedIssues(),
observations = assessment.observations,
disposition = assessment.disposition,
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
return assessment.toRiskSummary()
}
/**
* Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through
* [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads
* the log, never live state); empty when no grants have been emitted.
*/
internal suspend fun SessionOrchestrator.emitToolArtifacts(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
) {
stageConfig.produces
.filter { !it.kind.llmEmitted }
.forEach { slot ->
// A file_written slot must be backed by real content materialised from an actual
// successful write (see recordToolExecution). If none happened, do NOT fabricate a
// validated artifact (F-015) — leave the slot unproduced so verifyProduces fails the
// stage and it retries, rather than reporting a green run that changed nothing.
if (slot.kind.id == "file_written" &&
artifactContentCache["${sessionId.value}:${slot.name.value}"] == null
) {
return@forEach
}
emitAll(
sessionId,
listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
}
}
internal suspend fun SessionOrchestrator.recordToolExecution(
sessionId: SessionId,
stageId: StageId,
toolCall: ToolCallRequest,
invocationId: ToolInvocationId,
tier: Tier,
result: ToolResult,
fileTool: FileAffectingTool?,
request: ToolRequest,
fileWrittenSlots: List<TypedArtifactSlot>,
fullOutputHash: String? = null,
) {
// Invariant #5: every tool side effect is captured. This is the single authoritative
// ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
// FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
// emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
when (result) {
is ToolResult.Failure -> emit(
sessionId,
ToolExecutionFailedEvent(invocationId, sessionId, toolCall.function.name, result.reason),
)
is ToolResult.Success -> {
val diff = result.metadata["diff"]?.takeIf { it.isNotBlank() }
val affected = fileTool?.affectedPaths(request).orEmpty()
// Read-only tools (file_read, list_dir) have no FileAffectingTool, so `affected`
// is empty — fall back to the raw `path` argument so the receipt still names
// the file/dir the tool looked at (surfaced in the TUI's output row).
val affectedNames = affected.map { it.toString() }
.ifEmpty { listOfNotNull(request.parameters["path"] as? String) }
emit(
sessionId,
ToolExecutionCompletedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
receipt = ToolReceipt(
invocationId = invocationId,
toolName = toolCall.function.name,
exitCode = result.exitCode,
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
// session projections can read it.
structuredOutput = result.metadata,
affectedEntities = affectedNames,
durationMs = 0,
tier = tier,
timestamp = Clock.System.now(),
diff = diff,
fullOutputHash = fullOutputHash,
),
),
)
if (affected.isNotEmpty()) {
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
}
}
}
}
/**
* Materialise a [FileWrittenArtifact] from the actual on-disk file after a successful mutation,
* store it as the stage's file_written slot content, and record [ArtifactContentStoredEvent].
* This ties the file_written artifact to a real write so validation can no longer be faked.
*/
internal suspend fun SessionOrchestrator.materializeFileWritten(
sessionId: SessionId,
stageId: StageId,
request: ToolRequest,
affected: Set<java.nio.file.Path>,
fileWrittenSlots: List<TypedArtifactSlot>,
diff: String?,
) {
if (fileWrittenSlots.isEmpty()) return
val relPath = request.parameters["path"] as? String ?: return
val onDisk = affected.firstOrNull() ?: Paths.get(relPath)
val bytes = runCatching { Files.readAllBytes(onDisk) }.getOrNull() ?: return
val contentHash = artifactStore.put(bytes)
val artifact = FileWrittenArtifact(
path = relPath,
contentHash = contentHash.value,
size = bytes.size.toLong(),
mode = "0644",
diff = diff,
)
val json = Json.encodeToString(FileWrittenArtifact.serializer(), artifact)
val storedHash = artifactStore.put(json.toByteArray())
fileWrittenSlots.forEach { slot ->
artifactContentCache["${sessionId.value}:${slot.name.value}"] = json
emit(
sessionId,
ArtifactContentStoredEvent(
artifactId = slot.name,
contentHash = storedHash,
sessionId = sessionId,
stageId = stageId,
),
)
}
}
internal suspend fun SessionOrchestrator.emitLlmArtifacts(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
) {
stageConfig.produces
.filter { it.kind.llmEmitted }
.forEach { slot ->
// Same rule as the file_written gate (F-015): no stored content means the
// model never produced this artifact — do not fabricate a validated one.
val cached = artifactContentCache["${sessionId.value}:${slot.name.value}"]
if (cached.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage={} slot={}: no content stored — skipping artifact emission",
stageId.value,
slot.name.value,
)
return@forEach
}
emitAll(
sessionId,
listOf(
ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1),
ArtifactValidatingEvent(slot.name, sessionId, stageId),
ArtifactValidatedEvent(slot.name, sessionId, stageId),
),
)
}
}
@@ -0,0 +1,22 @@
package com.correx.core.kernel.orchestration
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.execution.StageExecutionResult
/** Per-run effective tool wiring (workspace-scoped registry/executor/policy or the orchestrator defaults). */
internal data class RunEffectives(
val registry: ToolRegistry?,
val executor: ToolExecutor?,
val policy: WorkspacePolicy?,
)
/** Result of the artifact extraction/repair ladder: usable text, or a hard stage failure. */
internal sealed interface ArtifactLadderOutcome {
data class Text(val text: String) : ArtifactLadderOutcome
data class Reject(val failure: StageExecutionResult.Failure) : ArtifactLadderOutcome
}
/** A tool result rendered for the model, with the CAS hash of the full (pre-truncation) output if spilled. */
internal data class RenderedToolResult(val content: String, val fullOutputHash: String?)
@@ -0,0 +1,198 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.ArtifactState
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.ArtifactId
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.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision
import kotlinx.datetime.Clock
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
// A provider 4xx client error (e.g. llama.cpp 400 "invalid_request") is a deterministic
// request-construction failure — retrying re-sends the identical bad request and exhausts the
// budget for nothing (F-002). Treat 4xx as terminal, except 408 (timeout) and 429 (rate-limit)
// which are transient. Everything else (network/5xx/unknown) stays retryable.
internal fun SessionOrchestrator.isRetryableFailure(reason: String): Boolean {
val status = Regex("""returned\s+(\d{3})""").find(reason)?.groupValues?.get(1)?.toIntOrNull()
?: return true
return status !in 400..499 || status == HTTP_TIMEOUT || status == HTTP_TOO_MANY_REQUESTS
}
// --- transition helpers ---
internal fun SessionOrchestrator.resolveTransition(
graph: WorkflowGraph,
sessionId: SessionId,
currentStageId: StageId,
artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
artifactContent: Map<ArtifactId, String> = emptyMap(),
): TransitionDecision {
val ctx = EvaluationContext(
sessionId = sessionId,
currentStage = currentStageId,
artifacts = artifacts,
artifactContent = artifactContent,
readyTaskCount = readyTaskCounter?.count(sessionId) ?: 0,
)
return transitionResolver.resolve(graph, ctx)
}
internal fun SessionOrchestrator.isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean =
graph.transitions.none { it.from == stageId }
internal suspend fun SessionOrchestrator.advanceStage(
sessionId: SessionId,
fromStageId: StageId,
decision: TransitionDecision.Move,
): StageId {
emit(
sessionId,
TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId, decision.redirectId),
)
return decision.to
}
// --- terminal state helpers ---
internal suspend fun SessionOrchestrator.emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) {
emit(
sessionId,
WorkflowStartedEvent(
sessionId = sessionId,
workflowId = graph.id,
startStageId = graph.start,
retryPolicy = config.retryPolicy,
),
)
}
internal suspend fun SessionOrchestrator.completeWorkflow(
sessionId: SessionId,
terminalStageId: StageId,
stageCount: Int,
workflowId: String,
): WorkflowResult.Completed {
log.debug(
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
sessionId.value, terminalStageId.value, stageCount,
)
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId)
}
internal suspend fun SessionOrchestrator.failWorkflow(
sessionId: SessionId,
stageId: StageId,
reason: String,
retryExhausted: Boolean,
): WorkflowResult.Failed {
log.warn(
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
sessionId.value, stageId.value, reason, retryExhausted,
)
// failWorkflow IS the clean-termination path: whatever goes wrong while recording it
// (event-store hiccup, correlation lookup, or any other transient failure) must never be
// allowed to escape as a raw exception — that would blow past this function's own
// WorkflowFailedEvent (correct stageId/reason/retryExhausted) and land in the server's
// top-level catch-all, which fabricates its own event with the wrong stage (graph.start)
// and a garbage reason (the escaping throwable's message/toString). Record what we can and
// still return a clean Failed result either way.
// Critique correlation is best-effort bookkeeping; if it throws we log and press on, because
// the terminal WorkflowFailedEvent below MUST still be recorded — skipping it would leave the
// event log with no terminal marker for the session (breaks replay-completeness, invariants
// #1/#8) and let the server catch-all fabricate a wrong one.
runCatching { correlateCritiqueOutcomes(sessionId) }.onFailure { e ->
log.error(
"[Orchestrator] failWorkflow: critique correlation failed session={} stage={}",
sessionId.value, stageId.value, e,
)
}
// Last-ditch: the one unrecoverable case. If emitting the terminal event itself throws
// (e.g. a serialization edge), we cannot record the failure at all — log it loudly with the
// full throwable so it is never silent, then still return a clean Failed result.
runCatching {
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
}.onFailure { e ->
log.error(
"[Orchestrator] failWorkflow: FAILED to record terminal WorkflowFailedEvent — event " +
"log has no terminal marker for session={} stage={}",
sessionId.value, stageId.value, e,
)
}
cancellations.remove(sessionId)
evictArtifactContentCache(sessionId)
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
}
/**
* At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
* feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
* recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
* — it skips when outcomes already exist — so it is safe on every terminal path.
*/
internal suspend fun SessionOrchestrator.correlateCritiqueOutcomes(sessionId: SessionId) {
val events = eventStore.read(sessionId)
if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
if (reviews.isEmpty()) return
CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
}
internal suspend fun SessionOrchestrator.emit(sessionId: SessionId, payload: EventPayload) {
log.debug("[session {}] emitting event, payload: {}", sessionId.value.take(7), payload)
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
/** Appends [payloads] as one transaction (one flow-publish pass) for events that belong together. */
internal suspend fun SessionOrchestrator.emitAll(sessionId: SessionId, payloads: List<EventPayload>) {
if (payloads.size <= 1) {
payloads.firstOrNull()?.let { emit(sessionId, it) }
return
}
eventStore.appendAll(
payloads.map { payload ->
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
)
},
)
}
@@ -0,0 +1,159 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.SessionContextProjection
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.sessions.projections.EgressAllowlistProjection
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.tools.contract.ToolCapability
import com.correx.core.transitions.graph.StageConfig
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.*
import java.util.concurrent.atomic.*
internal fun SessionOrchestrator.resolveSessionEgressHosts(sessionId: SessionId): Set<String> {
val projection = EgressAllowlistProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
/**
* Folds this session's tool events through [SessionContextProjection] into the read-model the
* anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log,
* never live state); empty before anything has happened.
*/
internal fun SessionOrchestrator.resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext {
val projection = SessionContextProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
/**
* The out-of-workspace path a read tool targets, or null if the read stays inside the workspace
* (or there's no workspace policy / path arg). Resolution is purely lexical (absolute-normalize,
* or resolve-against-root for relatives) so the containment decision is deterministic and never
* touches the filesystem. Symlink escapes and privileged locations are caught upstream by the
* plane-2 PathContainmentRule (BLOCK before any prompt), and by the tool's own realize() jail.
*/
internal fun SessionOrchestrator.outsideWorkspaceTarget(parameters: Map<String, Any>, workspaceRoot: Path?): Path? {
workspaceRoot ?: return null
val raw = parameters["path"] as? String ?: return null
val candidate = runCatching { Path.of(raw) }.getOrNull() ?: return null
val root = workspaceRoot.toAbsolutePath().normalize()
val resolved = (if (candidate.isAbsolute) candidate else root.resolve(candidate)).normalize()
return resolved.takeUnless { it.startsWith(root) }
}
/**
* Paths outside the workspace the operator has approved this session (folded from
* [OutsidePathAccessGrantedEvent]). Replay-safe: reads the log, never re-prompts or re-probes.
*/
internal fun SessionOrchestrator.grantedOutsidePaths(sessionId: SessionId): Set<String> =
eventStore.read(sessionId)
.mapNotNull { it.payload as? OutsidePathAccessGrantedEvent }
.filter { it.sessionId == sessionId }
.map { it.path }
.toSet()
/**
* Returns true when the agent must be offered only read-only tools on the next inference turn.
* Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
* Derived purely from the event log — no separate flag stored.
*/
internal fun SessionOrchestrator.isReadOnlyMode(sessionId: SessionId): Boolean {
var blocked = false
val pendingReadIds = mutableSetOf<String>()
for (event in eventStore.read(sessionId)) {
when (val p = event.payload) {
is ToolInvocationRequestedEvent ->
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
pendingReadIds += p.invocationId.value
is ToolCallAssessedEvent ->
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
when {
p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
blocked = true
pendingReadIds.clear()
}
// A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
// produce a completion, so it can't be what lifts read-only mode. Treat the
// attempt as satisfying read-before-write: the file is absent, so there is
// nothing to read before creating it. Without this, a stage that writes a NEW
// file deadlocks — writes are filtered out and every read of the not-yet-created
// target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
blocked = false
pendingReadIds.clear()
}
}
}
is ToolExecutionCompletedEvent ->
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
blocked = false
pendingReadIds.clear()
}
else -> {}
}
}
return blocked
}
internal fun SessionOrchestrator.mandateSuppressedByTicket(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): Boolean {
if (stageConfig.metadata["role"] == "recovery") return false
return eventStore.read(sessionId)
.mapNotNull { it.payload as? TransitionExecutedEvent }
.lastOrNull { it.to == stageId }
?.transitionId == TICKET_ROUTE
}
/**
* Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
* path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
* `com/correx/apps/server/...`) still resolves. Null when nothing shares the filename, so we
* only ever suggest a concrete, existing file. Reads the recorded map (replay-safe).
*/
internal fun SessionOrchestrator.suggestClosestPath(sessionId: SessionId, guessed: String): String? {
val map = eventStore.read(sessionId)
.mapNotNull { it.payload as? RepoMapComputedEvent }
.lastOrNull() ?: return null
return closestPathByFilename(guessed, map.entries.map { it.path })
}
/**
* Resolves the actual prompt text a stage will see, for use as the repo-knowledge query.
* `metadata["prompt"]` holds a *path* to the prompt file (resolved into context separately,
* around line 431) — using that path string itself as the embedding query means the query the
* retriever compares against is a filesystem path, not the stage's real instructions, so
* similarity scores never reflect genuine relevance (2026-07-08 finding: no file scored a
* clear match, everything clustered in the embedder's baseline noise band for short strings).
*/
internal suspend fun SessionOrchestrator.handleCancellation(
sessionId: SessionId,
stageId: StageId,
): WorkflowResult.Cancelled {
log.warn("[Orchestrator] CANCELLED session={} stage={}", sessionId.value, stageId.value)
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
cancellations.remove(sessionId)
return WorkflowResult.Cancelled(sessionId)
}
// --- cancellation ---
internal fun SessionOrchestrator.isCancelled(sessionId: SessionId): Boolean =
cancellations[sessionId]?.get() == true
@@ -20,13 +20,17 @@ class ReplayInferenceProvider(
override val id: ProviderId = ProviderId("replay-provider")
@Suppress("MagicNumber")
private companion object {
/** Rough char→token ratio for the replay tokenizer (no real BPE at replay time). */
const val CHARS_PER_TOKEN = 4
}
override val tokenizer: Tokenizer = object : Tokenizer {
override suspend fun tokenize(text: String): List<Token> =
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars
List(text.chunked(CHARS_PER_TOKEN).size) { i -> Token(i) }
override suspend fun countTokens(text: String): Int =
(text.length + 3) / 4
(text.length + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN
}
override suspend fun infer(request: InferenceRequest): InferenceResponse {
@@ -6,7 +6,8 @@ import org.junit.jupiter.api.Test
class ArtifactPolicyDecisionTest {
private fun decideFresh(f: ArtifactFailure) = decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
private fun decideFresh(f: ArtifactFailure) =
decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false)
@Test
fun `unsafe always aborts, even with budget and approver`() {
@@ -0,0 +1,30 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FrameTruncatedToolResultTest {
@Test
fun `keeps header, head, tail and a marker naming the retrieval ref`() {
val body = (1..300).joinToString("\n") { "line-$it" }
val out = frameTruncatedToolResult("[grep exit=0]", body, "abc123")
assertTrue(out.startsWith("[grep exit=0]"), out)
assertTrue(out.contains("line-1\n"), "head lines kept")
assertTrue(out.contains("line-300"), "tail lines kept")
assertTrue(out.contains("output truncated (300 lines)"), out)
assertTrue(out.contains("tool_output(ref=\"abc123\")"), out)
// Middle is dropped — a line well inside the elided span must be absent.
assertTrue(!out.contains("line-150"), "middle elided")
}
@Test
fun `char-caps a single pathological long line so the bound is not defeated`() {
val giant = "x".repeat(1_000_000)
val out = frameTruncatedToolResult("[shell exit=0]", giant, "ref")
// One line means head==tail==giant; each is char-capped, so output can't be ~2MB.
assertTrue(out.length < 20_000, "length was ${out.length}")
assertTrue(out.contains("tool_output(ref=\"ref\")"), out)
}
}
@@ -51,7 +51,8 @@ class JournalCompactionServiceTest {
}
@Test
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking {
fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`():
Unit = runBlocking {
val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 })
val state = stateWithRecords(
makeRecord(1, DecisionKind.INTENT), // HIGH
@@ -108,6 +108,9 @@ class DefaultTalkieContextBuilder(
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
private const val MAX_IDEAS_IN_CONTEXT = 10
/** Rough chars-per-token heuristic used when no tokenizer is available. */
private const val CHARS_PER_TOKEN_ESTIMATE = 4
}
override suspend fun build(
@@ -389,7 +392,7 @@ class DefaultTalkieContextBuilder(
}
private fun fallbackEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1)
return (content.length / CHARS_PER_TOKEN_ESTIMATE).coerceAtLeast(1)
}
/**
@@ -146,7 +146,12 @@ class DefaultTalkieFacade(
// Rebuild state so lastRetrievedMemory reflects this turn
state = routerRepository.getTalkieState(sessionId)
val contextPack = routerContextBuilder.build(state, config.tokenBudget, workflowSummaryProvider(), sessionProfileProvider(sessionId))
val contextPack = routerContextBuilder.build(
state,
config.tokenBudget,
workflowSummaryProvider(),
sessionProfileProvider(sessionId),
)
if (contextPack.compressionMetadata.entriesDropped > 0) {
val truncNow = Clock.System.now()
@@ -195,7 +200,8 @@ class DefaultTalkieFacade(
"without producing an answer (likely spent on hidden reasoning). " +
"Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
} else {
"The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
"The model returned an empty response " +
"(finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
}
}
@@ -33,13 +33,22 @@ data class TaskContextBundle(
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
goal?.let { appendLine("goal: $it") }
if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):")
appendList("blocked_by", blockedBy) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("blocked_by", blockedBy) {
" - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd()
}
appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
appendList("dependencies", dependencies) {
" - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd()
}
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() }
appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() }
appendList("artifacts", artifacts) {
val stageSuffix = it.stage?.let { s -> " @$s" }.orEmpty()
" - ${it.targetId} (${it.type})$stageSuffix: ${it.excerpt.orEmpty()}".trimEnd()
}
appendList("sessions", sessions) {
" - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd()
}
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
@@ -118,7 +118,12 @@ class DefaultTaskReducerTest {
val state = reduceAll(
created(),
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")),
TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK),
TaskLinkedEvent(
taskId,
targetId = "auth-138",
type = TaskLinkType.DEPENDS_ON,
targetKind = TaskTargetKind.TASK,
),
TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
)
@@ -43,7 +43,8 @@ class ManifestContainmentRule : ToolCallRule {
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw)
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedInput =
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedReal = input.probe.resolveReal(resolvedInput)
val inWorkspace = resolvedReal.startsWith(workspaceReal)
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
@@ -35,7 +35,8 @@ class PathContainmentRule : ToolCallRule {
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw)
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedInput =
if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
val resolvedReal = input.probe.resolveReal(resolvedInput)
val exists = input.probe.exists(resolvedInput)
val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
@@ -26,7 +26,13 @@ class ReferenceExistsRuleTest {
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)),
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"file_read",
mapOf("path" to pathArg),
),
capabilities = setOf(ToolCapability.FILE_READ),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = probe,
@@ -27,7 +27,13 @@ class StaleWriteRuleTest {
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)),
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"file_edit",
mapOf("path" to target),
),
capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(mapOf(abs(target) to onDisk)),
@@ -23,7 +23,13 @@ class WriteScopeRuleTest {
}
private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)),
request = ToolRequest(
ToolInvocationId("i"),
SessionId("s"),
StageId("st"),
"file_write",
mapOf("path" to pathArg),
),
capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(),
@@ -34,7 +40,10 @@ class WriteScopeRuleTest {
@Test
fun `no active task means nothing to enforce`() {
assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition)
assertEquals(
RiskAction.PROCEED,
rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition,
)
}
@Test
@@ -38,6 +38,14 @@ data class StageConfig(
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
val expectedFiles: List<String> = emptyList(),
// Declared scope for this stage (architecture-conformance, design 2026-07-14): the
// workspace-relative globs the stage's intent is confined to (e.g. a frontend-client stage
// declares `frontend/**`). Distinct from [writeManifest], which is DERIVED from the concrete
// `writes` and so cannot contradict them: `touches` is the architect's separate statement of
// intent, and the plan compiler rejects a plan whose declared `writes` escape it (scope creep
// caught at plan time, before any code is written). Also fed to the semantic reviewer so it can
// judge goal-conformance against the declared scope. Empty = no scope constraint (default).
val touches: List<String> = emptyList(),
// Opt-in to the Gate 3 semantic (LLM) reviewer for this stage (staged-verification § Gate 3). When
// true and a reviewer is wired, the reviewer reads this stage's produced files after the
// deterministic funnel passes and raises advisory PR-comment findings; a high-confidence
@@ -66,6 +74,7 @@ data class StageConfig(
companion object {
/** Read-only tools every tool-granting stage may call regardless of its declared set. */
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> = setOf("file_read", "list_dir", "glob", "grep")
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> =
setOf("file_read", "list_dir", "glob", "grep", "tool_output")
}
}
@@ -20,7 +20,10 @@ class ArtifactExtractionPipelineTest {
)
private fun resolved(raw: String) =
assertInstanceOf(ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java, pipeline.run(raw, fileWritten))
assertInstanceOf(
ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java,
pipeline.run(raw, fileWritten),
)
@Test
fun `clean json passes through`() {
@@ -45,7 +45,10 @@ class JsonSchemaValidatorTest {
@Test
fun `unknown property is allowed when additionalProperties is true`() {
val open = schema.copy(additionalProperties = true)
val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""))
val result = JsonSchemaValidator.validate(
open,
Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""),
)
assertTrue(result.isEmpty())
}
+1 -1
View File
@@ -1,5 +1,5 @@
build:
maxIssues: 120
maxIssues: 90
style:
active: true
@@ -28,7 +28,9 @@ Emit a JSON object that validates against the `execution_plan` schema:
"produces": "<unique artifact_id this stage emits, your choice of name>",
"kind": "<one of the available artifact kinds listed in your context>",
"needs": ["<artifact_id consumed from an earlier stage>"],
"tools": ["file_read", "file_write", "file_edit", "shell"]
"tools": ["file_read", "file_write", "file_edit", "shell"],
"writes": ["<workspace-relative path or glob this stage will write>"],
"touches": ["<glob the stage is confined to, e.g. frontend/**>"]
}
],
"edges": [
@@ -79,6 +81,14 @@ Emit a JSON object that validates against the `execution_plan` schema:
decompose tasks here — task creation is out of scope for the plan.
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
prompts.
- **Declare `touches` to fence a stage to its area.** When a stage's job belongs to one part of
the tree (a frontend/client stage → `["frontend/**"]`; a stage that edits only the API layer →
`["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes`
must fall within its `touches`, or the plan is rejected at compile time. This is how you stop a
stage from silently widening past its intent — e.g. a "build the web approval **client**" stage
that also starts adding backend routes. Set `touches` narrowly on any stage whose scope is a
single area; omit it only for genuinely cross-cutting stages. If a change truly needs a
different area, give it its own stage that owns that area rather than widening an unrelated one.
- **Prefer the real scaffolder over hand-writing config.** A stage that sets up a project should
use the package manager's own generator — e.g. `npm create vite@latest <dir> -- --template
react-ts` — because it produces a correct, complete, current file set (a hand-written
@@ -154,9 +154,6 @@ class DefaultModelManager(
private suspend fun waitForHealthy(process: LlamaProcess): Boolean {
val endTime = Clock.System.now().toEpochMilliseconds() + healthTimeoutMs
while (Clock.System.now().toEpochMilliseconds() < endTime) {
// A crashed process (bad --model, port clash) never gets healthy; polling the full
// timeout is pure dead time. Bail as soon as it's gone.
if (!process.isAlive) return false
try {
val response = httpClient.get("http://$host:$port/health").body<String>()
if (response.contains("\"status\":\"healthy\"") || response.contains("ok")) {
@@ -165,6 +162,10 @@ class DefaultModelManager(
} catch (_: Exception) {
// Continue polling
}
// A crashed process (bad --model, port clash) never gets healthy; polling the full
// timeout is pure dead time. Bail as soon as it's gone — but only after probing health,
// so a server that's already answering isn't missed on a lost startup/exit race.
if (!process.isAlive) return false
delay(POLL_INTERVAL)
}
return false
@@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L
private const val ERROR_BODY_PREVIEW_LENGTH = 200
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
@@ -125,9 +126,10 @@ class LlamaCppEmbedder(
return nestedResult
}
val preview = responseBody.take(ERROR_BODY_PREVIEW_LENGTH)
throw IllegalStateException(
"Unexpected embedding response shape from $url. " +
"Response body (first 200 chars): ${responseBody.take(200)}"
"Response body (first $ERROR_BODY_PREVIEW_LENGTH chars): $preview"
)
}
}
@@ -260,7 +260,8 @@ class LlamaCppInferenceProvider(
when {
line.startsWith("id = ") -> id = line.removePrefix("id = ").trim()
line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").trim()
line.startsWith("function.arguments = ") -> arguments = line.removePrefix("function.arguments = ").trim()
line.startsWith("function.arguments = ") ->
arguments = line.removePrefix("function.arguments = ").trim()
}
}
return if (name != null && arguments != null) {
@@ -41,7 +41,11 @@ class SqliteEventStoreConcurrencyTest {
assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}")
val sessionSeqs = events.map { it.sessionSequence }.sorted()
assertEquals((1L..n.toLong()).toList(), sessionSeqs, "Session sequences must be exactly 1..$n with no gaps or duplicates")
assertEquals(
(1L..n.toLong()).toList(),
sessionSeqs,
"Session sequences must be exactly 1..$n with no gaps or duplicates",
)
val globalSeqs = events.map { it.sequence }
assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique")
@@ -84,12 +84,17 @@ object InfrastructureModule {
private val defaultArtifactsRoot: String =
"${System.getProperty("user.home")}/.config/correx/artifacts"
private const val LLAMA_CODING_SCORE = 0.7
private const val LLAMA_REASONING_SCORE = 0.6
private const val LLAMA_SUMMARIZATION_SCORE = 0.8
private const val LLAMA_TOOL_CALLING_SCORE = 0.5
private val DEFAULT_LLAMA_CAPABILITIES: Set<CapabilityScore> = setOf(
CapabilityScore(ModelCapability.General, 1.0),
CapabilityScore(ModelCapability.Coding, 0.7),
CapabilityScore(ModelCapability.Reasoning, 0.6),
CapabilityScore(ModelCapability.Summarization, 0.8),
CapabilityScore(ModelCapability.ToolCalling, 0.5),
CapabilityScore(ModelCapability.Coding, LLAMA_CODING_SCORE),
CapabilityScore(ModelCapability.Reasoning, LLAMA_REASONING_SCORE),
CapabilityScore(ModelCapability.Summarization, LLAMA_SUMMARIZATION_SCORE),
CapabilityScore(ModelCapability.ToolCalling, LLAMA_TOOL_CALLING_SCORE),
)
fun createEventStore(artifactStore: ArtifactStore, dbPath: String = defaultDbPath): EventStore {
@@ -326,6 +331,17 @@ object InfrastructureModule {
val repository = DefaultTalkieRepository(replayer)
val ideaReader = IdeaReader(eventStore)
val contextBuilder = DefaultTalkieContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() })
return DefaultTalkieFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
return DefaultTalkieFacade(
repository,
contextBuilder,
inferenceRouter,
eventStore,
config,
null,
embedder,
l3MemoryStore,
workflowSummaryProvider,
sessionProfileProvider,
)
}
}
@@ -39,7 +39,8 @@ class FileWriteTool(
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write"
override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " +
override val description: String =
"Write content to a file at the specified relative path (creates or overwrites). " +
"Writing to a nested path auto-creates any missing parent directories — there is no separate " +
"mkdir step. Do not write shell commands into a file's content."
override val parametersSchema: JsonObject = buildJsonObject {
@@ -179,7 +179,9 @@ class ListDirTool(
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
val notes = buildList {
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
if (ignoredCount > 0) add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
if (ignoredCount > 0) {
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
}
}
return buildString {
appendLine("<path>$root</path>")
@@ -203,14 +205,14 @@ class ListDirTool(
.filter { it != targetName && isSimilarName(it, targetName) }
.toList()
.sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
.take(3)
.take(MAX_SIMILAR_SUGGESTIONS)
}
} catch (_: Exception) { emptyList() }
}
private fun isSimilarName(a: String, b: String): Boolean {
val prefix = commonPrefixLen(a, b)
return prefix >= 2 || levenshtein(a, b) <= 3
return prefix >= SIMILAR_NAME_MIN_COMMON_PREFIX || levenshtein(a, b) <= SIMILAR_NAME_MAX_LEVENSHTEIN
}
private fun commonPrefixLen(a: String, b: String): Int {
@@ -238,6 +240,9 @@ class ListDirTool(
private companion object {
const val MAX_ENTRIES = 400
const val MAX_SIMILAR_SUGGESTIONS = 3
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
}
}
@@ -35,6 +35,7 @@ private const val MAX_RESULTS = 200
// symbol search wants, and reading it line-by-line stalls the walk. ponytail: fixed byte cap; make it
// a param if a real use case needs to grep large generated files.
private const val GREP_MAX_FILE_BYTES = 2_000_000L
private const val GREP_LINE_PREVIEW_LENGTH = 200
/**
* `.gitignore`-aware path search by glob pattern — the affordance a model should reach for to answer
@@ -58,7 +59,10 @@ class GlobTool(
putJsonObject("properties") {
putJsonObject("pattern") {
put("type", "string")
put("description", "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.")
put(
"description",
"Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.",
)
}
putJsonObject("path") {
put("type", "string")
@@ -80,13 +84,27 @@ class GlobTool(
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
}
val pattern = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
?: return@withContext ToolResult.Failure(request.invocationId, "glob requires a non-empty 'pattern'", recoverable = true)
?: return@withContext ToolResult.Failure(
request.invocationId,
"glob requires a non-empty 'pattern'",
recoverable = true,
)
val base = resolveSearchPath(request, workingDir)
if (!Files.isDirectory(base)) {
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
return@withContext ToolResult.Failure(
request.invocationId,
"Not a directory: ${request.parameters["path"] ?: "."}",
recoverable = true,
)
}
val matcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:$pattern") }
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid glob pattern: ${it.message}", recoverable = true) }
.getOrElse {
return@withContext ToolResult.Failure(
request.invocationId,
"Invalid glob pattern: ${it.message}",
recoverable = true,
)
}
val hits = ArrayList<String>()
var truncated = false
@@ -147,16 +165,36 @@ class GrepTool(
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
}
val patternStr = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() }
?: return@withContext ToolResult.Failure(request.invocationId, "grep requires a non-empty 'pattern'", recoverable = true)
?: return@withContext ToolResult.Failure(
request.invocationId,
"grep requires a non-empty 'pattern'",
recoverable = true,
)
val regex = runCatching { Regex(patternStr) }
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid regex: ${it.message}", recoverable = true) }
.getOrElse {
return@withContext ToolResult.Failure(
request.invocationId,
"Invalid regex: ${it.message}",
recoverable = true,
)
}
val base = resolveSearchPath(request, workingDir)
if (!Files.isDirectory(base)) {
return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true)
return@withContext ToolResult.Failure(
request.invocationId,
"Not a directory: ${request.parameters["path"] ?: "."}",
recoverable = true,
)
}
val fileFilter = (request.parameters["glob"] as? String)?.takeIf { it.isNotBlank() }?.let {
runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") }
.getOrElse { e -> return@withContext ToolResult.Failure(request.invocationId, "Invalid glob: ${e.message}", recoverable = true) }
.getOrElse { e ->
return@withContext ToolResult.Failure(
request.invocationId,
"Invalid glob: ${e.message}",
recoverable = true,
)
}
}
val hits = ArrayList<String>()
@@ -169,7 +207,7 @@ class GrepTool(
if (text != null && !text.contains('\u0000')) {
text.lineSequence().forEachIndexed { idx, line ->
if (!truncated && regex.containsMatchIn(line)) {
hits += "$rel:${idx + 1}: ${line.trim().take(200)}"
hits += "$rel:${idx + 1}: ${line.trim().take(GREP_LINE_PREVIEW_LENGTH)}"
if (hits.size >= MAX_RESULTS) truncated = true
}
}
@@ -69,7 +69,8 @@ class WorkspaceSearchToolsTest {
fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking {
val root = fixture()
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))) as ToolResult.Success).output
val req = request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))
val out = (tool.execute(req) as ToolResult.Success).output
assertTrue(out.contains("src/hooks/useAuth.ts:"), out)
assertTrue(out.contains("src/main.ts:"), out)
assertFalse(out.contains("node_modules"), out) // gitignored
@@ -0,0 +1,68 @@
package com.correx.infrastructure.tools
import com.correx.core.approvals.Tier
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ArtifactId
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Retrieves the full text of an earlier tool result that was truncated for model context. When a
* Success output exceeds the context floor, the kernel spills the full raw output to the artifact
* store and shows a marker containing its `ref` (the content hash, also recorded on the
* ToolExecutionCompleted receipt in the event log). This tool resolves that `ref` back to the full
* content. Read-only (Tier T1, no filesystem capability — it reads the content-addressed store by a
* hash the model was explicitly handed, never arbitrary paths).
*/
class ToolOutputTool(private val artifactStore: ArtifactStore) : Tool, ToolExecutor {
override val name: String = "tool_output"
override val description: String =
"Retrieve the FULL output of an earlier tool call that was truncated. Pass the ref shown in " +
"the truncation marker (e.g. tool_output(ref=\"<hash>\"))."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("ref") {
put("type", "string")
put("description", "The ref/hash from a '… output truncated …' marker.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("ref")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult =
if ((request.parameters["ref"] as? String).isNullOrBlank()) {
ValidationResult.Invalid("tool_output requires a non-empty 'ref'")
} else {
ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val ref = (request.parameters["ref"] as? String)?.takeIf { it.isNotBlank() }
?: return ToolResult.Failure(
request.invocationId,
"tool_output requires a non-empty 'ref'",
recoverable = true,
)
return artifactStore.get(ArtifactId(ref))
?.let { ToolResult.Success(request.invocationId, output = it.toString(Charsets.UTF_8)) }
?: ToolResult.Failure(
request.invocationId,
"No stored output for ref '$ref' — it may have expired or the ref is wrong.",
recoverable = true,
)
}
}
@@ -119,10 +119,19 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
}
return null
}
val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!)
val duplicates = service.findDuplicates(
ProjectId(request.stringParam("project")!!),
request.stringParam("title")!!,
)
return duplicates.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
val listed = it.joinToString(", ") { d ->
"${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]"
}
fail(
request,
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), " +
"or force=true with force_reason.",
)
}
}
@@ -75,7 +75,10 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
putJsonObject("depends_on") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.")
put(
"description",
"refs (or 0-based indices) of tasks in THIS batch that must finish first.",
)
}
}
put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) })
@@ -123,7 +126,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
dedupFailure(request, project, specs, parent)?.let { return it }
val pid = ProjectId(project)
val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
val parentTask = parent?.let {
service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths)
}
val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
wireLinks(adjacency, children, parentTask)
recordProvenance(request, listOfNotNull(parentTask) + children)
@@ -140,7 +145,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
/** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
adjacency.forEachIndexed { i, deps ->
deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) }
deps.forEach { j ->
service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
}
}
if (parentTask != null) {
children.forEach { child ->
@@ -156,15 +163,26 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return
created.forEach {
service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason")
service.addNote(
it.taskId,
TaskNoteAuthor.AGENT,
"[force] created via decompose despite the duplicate guard: $reason",
)
}
}
/** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? {
private fun dedupFailure(
request: ToolRequest,
project: String,
specs: List<TaskSpec>,
parent: TaskSpec?,
): ToolResult.Failure? {
val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys
if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
if (within.isNotEmpty()) {
return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.")
}
if (request.boolParam("force")) {
return if (request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.")
@@ -175,8 +193,14 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
val pid = ProjectId(project)
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
return clashes.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" }
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.")
val listed = it.joinToString(", ") { d ->
"${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]"
}
fail(
request,
"Possible duplicate(s): $listed. Reuse one (task_context/task_update), " +
"or force=true with force_reason.",
)
}
}
@@ -194,7 +218,8 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
val readyLine = if (readyIds.isEmpty()) {
"Nothing is ready yet — check the dependency graph."
} else {
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete."
"Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; " +
"the rest unblock as their dependencies complete."
}
return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
lines.joinToString("\n") + "\n" + readyLine
@@ -230,7 +255,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
(obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
private fun strList(obj: JsonObject, key: String): List<String> =
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList()
(obj[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) }
?: emptyList()
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
private fun elementOf(request: ToolRequest, name: String) =
@@ -253,7 +280,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
val deps = ArrayList<Int>()
for (d in s.deps) {
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices }
?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.")
?: return Edges.Err(
"task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.",
)
if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
deps.add(j)
}
@@ -35,7 +35,10 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.")
}
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
putJsonObject("limit") {
put("type", "integer")
put("description", "Max results (default $DEFAULT_LIMIT).")
}
}
}
override val tier: Tier = Tier.T1
@@ -50,7 +53,9 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
val output = if (ready.isEmpty()) {
"no ready tasks"
} else {
ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
ready.joinToString("\n") {
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
}
}
return ToolResult.Success(
invocationId = request.invocationId,
@@ -35,12 +35,18 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
putJsonObject("query") {
put("type", "string")
put("description", "Free-text query; all terms must match.")
}
putJsonObject("project") {
put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
}
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
putJsonObject("limit") {
put("type", "integer")
put("description", "Max results (default $DEFAULT_LIMIT).")
}
}
put("required", buildJsonArray { add(JsonPrimitive("query")) })
}
@@ -61,7 +67,9 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
val output = if (hits.isEmpty()) {
"no tasks match \"$query\""
} else {
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
hits.joinToString("\n") {
"${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd()
}
}
return ToolResult.Success(
invocationId = request.invocationId,
@@ -69,23 +69,35 @@ class TaskUpdateTool(
}
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
putJsonObject("link_target") {
put("type", "string")
put("description", "Id to link to (task/artifact/session).")
}
putJsonObject("link_type") {
put("type", "string")
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
}
putJsonObject("link_kind") {
put("type", "string")
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
put(
"description",
"What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.",
)
}
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
putJsonObject("force") {
put("type", "boolean")
put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.")
put(
"description",
"Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.",
)
}
putJsonObject("force_reason") {
put("type", "string")
put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.")
put(
"description",
"Required when force=true: why the override is justified. Recorded as a note on the task.",
)
}
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
@@ -159,7 +171,10 @@ class TaskUpdateTool(
val unmet = service.blockers(taskId)
if (unmet.isEmpty()) return null
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" }
return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.")
return fail(
request,
"Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.",
)
}
/**
@@ -88,7 +88,7 @@ class WebFetchTool(
val contentType = response.headers[HttpHeaders.ContentType]
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
when {
response.status.value in 300..399 ->
response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX ->
fail(
invocationId,
"URL redirected (HTTP ${response.status.value}) to " +
@@ -159,5 +159,7 @@ class WebFetchTool(
companion object {
const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake
private const val READ_CHUNK = 8_192
private const val HTTP_REDIRECT_MIN = 300
private const val HTTP_REDIRECT_MAX = 399
}
}
@@ -0,0 +1,52 @@
package com.correx.infrastructure.tools
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ToolRequest
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.ToolInvocationId
import com.correx.core.tools.contract.ToolResult
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.*
class ToolOutputToolTest {
private class FakeStore(private val entries: Map<String, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
override suspend fun get(id: ArtifactId): ByteArray? = entries[id.value]
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
private fun request(ref: String?) = ToolRequest(
invocationId = ToolInvocationId(UUID.randomUUID().toString()),
sessionId = SessionId(UUID.randomUUID().toString()),
stageId = StageId(UUID.randomUUID().toString()),
toolName = "tool_output",
parameters = ref?.let { mapOf("ref" to it) } ?: emptyMap(),
)
@Test
fun `returns the full stored output for a known ref`(): Unit = runBlocking {
val tool = ToolOutputTool(FakeStore(mapOf("hash1" to "the full output".toByteArray())))
val out = (tool.execute(request("hash1")) as ToolResult.Success).output
assertEquals("the full output", out)
}
@Test
fun `unknown ref fails recoverably`(): Unit = runBlocking {
val tool = ToolOutputTool(FakeStore(emptyMap()))
val result = tool.execute(request("missing"))
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
}
@Test
fun `missing ref fails recoverably`(): Unit = runBlocking {
val tool = ToolOutputTool(FakeStore(emptyMap()))
val result = tool.execute(request(null))
assertTrue(result is ToolResult.Failure && result.recoverable, result.toString())
}
}

Some files were not shown because too many files have changed in this diff Show More