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.
This commit is contained in:
+6
-2748
File diff suppressed because it is too large
Load Diff
+195
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+252
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
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.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 operator's answers to a stage's open questions as an L2 USER entry, so the stage
|
||||
* sees its own questions resolved on the clarification re-run. 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.L2,
|
||||
content = content,
|
||||
sourceType = "clarificationAnswer",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-149
@@ -1,175 +1,26 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
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.DefaultApprovalRepository
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
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.ArtifactState
|
||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
||||
import com.correx.core.artifacts.kind.KindContractTable
|
||||
import com.correx.core.artifacts.kind.KindInference
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
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.artifactstore.ArtifactStore
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.context.builder.RequiredContextOverflowException
|
||||
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.ContextPack
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
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.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanCompileCheckedEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.events.ContractAssertionResult
|
||||
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.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
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.StaticAnalysisFinding
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
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.GroundingReference
|
||||
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.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeHit
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
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.NewEvent
|
||||
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.RiskAssessedEvent
|
||||
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.SteeringNoteAddedEvent
|
||||
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.SessionContextProjection
|
||||
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||
import com.correx.core.toolintent.ToolCallAssessor
|
||||
import com.correx.core.toolintent.WorkspacePolicy
|
||||
import com.correx.core.toolintent.WorldProbe
|
||||
import com.correx.core.toolintent.toAssessedIssues
|
||||
import com.correx.core.toolintent.toRiskSummary
|
||||
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.stores.EventStore
|
||||
import com.correx.core.sessions.projections.EgressAllowlistProjection
|
||||
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.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
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.ToolInvocationId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceRepository
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.inference.PromptRenderer
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.inference.ToolCallRequest
|
||||
import com.correx.core.inference.ToolDefinition
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.inference.ToolFunction
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.risk.RiskAssessor
|
||||
import com.correx.core.risk.RiskContext
|
||||
import com.correx.core.risk.toApprovalTier
|
||||
import com.correx.core.sessions.Session
|
||||
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.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.evaluation.PromptResolver
|
||||
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.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import com.correx.core.transitions.resolution.TransitionResolver
|
||||
import com.correx.core.validation.artifact.ArtifactExtractionPipeline
|
||||
import com.correx.core.validation.artifact.ArtifactFailure
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import com.correx.core.validation.pipeline.ValidationOutcome
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
||||
import org.slf4j.LoggerFactory
|
||||
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.*
|
||||
|
||||
+387
@@ -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.
|
||||
*/
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
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())
|
||||
|
||||
val objective = 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("; ")
|
||||
}
|
||||
}
|
||||
+292
@@ -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(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -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.
|
||||
*/
|
||||
+732
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+198
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+159
@@ -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
|
||||
Reference in New Issue
Block a user