diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index df275ac0..a928bf21 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1,117 +1,29 @@ 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 @@ -119,57 +31,35 @@ 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.* @@ -350,24 +240,11 @@ abstract class SessionOrchestrator( * actually be answered — a request whose deferred died with a server restart is excluded, so the * client never shows a modal that would resume nothing. */ - fun liveClarificationRequestIds(): Set = - pendingClarifications.keys.mapTo(mutableSetOf()) { it.value } - - /** 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 val artifactContentCache: ConcurrentHashMap = ConcurrentHashMap() /** Drops a terminated session's cached artifact contents (the heaviest per-session state — full * file/JSON payloads). Safe: rehydrateArtifactContentCache rebuilds it from durable events if the * session is ever resumed. Called on WorkflowCompleted/WorkflowFailed. */ - internal fun 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 val artifactExtractionPipeline = ArtifactExtractionPipeline() abstract suspend fun run( @@ -378,1937 +255,13 @@ abstract class SessionOrchestrator( abstract suspend fun cancel(sessionId: SessionId) - // --- per-run effective registry + policy --- + /** Live clarification request ids across sessions (member: consumed cross-module by the WS layer). */ + fun liveClarificationRequestIds(): Set = + pendingClarifications.keys.mapTo(mutableSetOf()) { it.value } - internal fun 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 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 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 emitArtifactRepairAttempted( - sessionId: SessionId, - stageId: StageId, - slot: TypedArtifactSlot, - classification: ArtifactFailure, - rung: String, - ) = emit( - sessionId, - ArtifactRepairAttemptedEvent(slot.name, classification.name, rung, sessionId, stageId), - ) - - internal suspend fun 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 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 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() - } - - @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 approvalModeFor(profileMode: String?): ApprovalMode = - when (profileMode?.trim()?.lowercase()) { - "deny" -> ApprovalMode.DENY - "auto" -> ApprovalMode.AUTO - "yolo" -> ApprovalMode.YOLO - else -> ApprovalMode.PROMPT - } - - // 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 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 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 dispatchToolCalls( - sessionId: SessionId, - stageId: StageId, - toolCalls: List, - 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 { - 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() - 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 blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) { - if (toolName == SCOPE_PROPOSAL_TOOL) { - taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason") - } - } - - internal suspend fun runPlane2Assessment( - sessionId: SessionId, - stageId: StageId, - invocationId: ToolInvocationId, - toolName: String, - request: ToolRequest, - tool: Tool?, - effectives: RunEffectives, - writeManifest: List, - ): 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 fun resolveSessionEgressHosts(sessionId: SessionId): Set { - 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 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 outsideWorkspaceTarget(parameters: Map, 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 grantedOutsidePaths(sessionId: SessionId): Set = - 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 isReadOnlyMode(sessionId: SessionId): Boolean { - var blocked = false - val pendingReadIds = mutableSetOf() - 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 suspend fun buildSchemaEntries( - responseFormat: ResponseFormat, - stageId: StageId, - ): List { - 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 buildSteeringNoteEntries(sessionId: SessionId): List { - 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 buildClarificationAnswerEntries(sessionId: SessionId): List { - 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 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>() - 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 buildRepoMapEntries(sessionId: SessionId, stagePrompt: String = ""): List { - 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 fun 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 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 fun 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 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" - } - - internal suspend fun buildContextualRepoEntries( - sessionId: SessionId, - stageId: StageId, - stageConfig: StageConfig, - ): List { - 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 buildDocsCatalogEntry(sessionId: SessionId): List { - 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 repoEntriesOrMapFloor( - sessionId: SessionId, - hits: List, - stagePrompt: String, - ): List { - 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 fun stampBuckets(entries: List): List = entries.map { - if (it.sourceType in REQUIRED_SOURCE_TYPES) it.copy(bucket = ContextBucket.REQUIRED) else it - } - - internal suspend fun buildNeedsArtifactEntries( - sessionId: SessionId, - stageConfig: StageConfig, - criticIds: Set = emptySet(), - criticFromStage: String? = null, - ): List { - 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, - ) - } - } - - internal fun 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 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 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 recordToolExecution( - sessionId: SessionId, - stageId: StageId, - toolCall: ToolCallRequest, - invocationId: ToolInvocationId, - tier: Tier, - result: ToolResult, - fileTool: FileAffectingTool?, - request: ToolRequest, - fileWrittenSlots: List, - 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 materializeFileWritten( - sessionId: SessionId, - stageId: StageId, - request: ToolRequest, - affected: Set, - fileWrittenSlots: List, - 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 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), - ), - ) - } - } - - internal fun 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 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. - */ - internal suspend fun 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 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 runPostStageGates( - sessionId: SessionId, - stageId: StageId, - stageConfig: StageConfig, - effectives: RunEffectives, - profileCommands: Map, - ): 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 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 evaluateStageContract( - sessionId: SessionId, - stageId: StageId, - stageConfig: StageConfig, - effectives: RunEffectives, - ): List? { - 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 contractFailureItems(results: List): List> = - results.filterNot { it.passed }.map { Triple(it.target, it.assertionId, it.evidence) } - - internal suspend fun 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 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 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 stageWrittenPaths(sessionId: SessionId, stageId: StageId): List { - 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. - */ - internal suspend fun 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 runExecutionGate( - sessionId: SessionId, - stageId: StageId, - stageConfig: StageConfig, - effectives: RunEffectives, - profileCommands: Map, - ): 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 runSetupCommand( - sessionId: SessionId, - stageId: StageId, - workspaceRoot: Path, - profileCommands: Map, - 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 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 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), - ), - ) - } - } + /** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */ + suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean = + requestStageApproval(sessionId, StageId("plan_review"), preview) internal open suspend fun mapValidationOutcome( sessionId: SessionId, @@ -2322,43 +275,6 @@ abstract class SessionOrchestrator( is ValidationOutcome.NeedsApproval -> handleApproval(sessionId, stageId, outcome) } - // 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 emitArtifactTool(stageConfig: StageConfig): List { - 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, - ), - ), - ) - } - - // 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 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("; ") - } - } - @Suppress("LongParameterList") internal open suspend fun runInference( sessionId: SessionId, @@ -2499,218 +415,6 @@ abstract class SessionOrchestrator( } } - // 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 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 resolveTransition( - graph: WorkflowGraph, - sessionId: SessionId, - currentStageId: StageId, - artifacts: Map = emptyMap(), - artifactContent: Map = 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 isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean = - graph.transitions.none { it.from == stageId } - - internal suspend fun 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 emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) { - emit( - sessionId, - WorkflowStartedEvent( - sessionId = sessionId, - workflowId = graph.id, - startStageId = graph.start, - retryPolicy = config.retryPolicy, - ), - ) - } - - internal suspend fun 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 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 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 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 isCancelled(sessionId: SessionId): Boolean = - cancellations[sessionId]?.get() == true - - // --- 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 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 suspend fun 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 emitAll(sessionId: SessionId, payloads: List) { - 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, - ) - }, - ) - } - // --- token estimation --- internal open suspend fun estimateTokens(content: String): Int { @@ -2727,422 +431,8 @@ abstract class SessionOrchestrator( return fallbackTokenEstimate(content) } - internal fun 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. - */ - internal suspend fun requestStageApproval( - sessionId: SessionId, - stageId: StageId, - preview: String, - ): Boolean { - val requestId = ApprovalRequestId(UUID.randomUUID().toString()) - val validationReportId = ValidationReportId(UUID.randomUUID().toString()) - val deferred = CompletableDeferred() - 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) - } - } - - /** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */ - suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean = - requestStageApproval(sessionId, StageId("plan_review"), preview) - - // --- private functions --- - - internal suspend fun 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() - 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 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 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, - ), - ) - } } -/** - * 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, - 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, - 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? { - val argv = when (val raw = parameters["argv"]) { - is List<*> -> raw.filterIsInstance() - is String -> runCatching { Json.decodeFromString>(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, newLines: List): List { - 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() - 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, 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() - 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? { - 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 - } -} internal sealed interface InferenceResult { data class Success(val response: InferenceResponse, val responseArtifactId: ArtifactId? = null) : InferenceResult @@ -3177,35 +467,3 @@ internal fun parseToolArguments(arguments: String): Map = runCatchi } as Any } }.getOrElse { emptyMap() } - -/** - * 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? { - 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(")") - } - } -} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorApproval.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorApproval.kt new file mode 100644 index 00000000..7987860a --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorApproval.kt @@ -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() + 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() + 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, + ), + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorArtifacts.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorArtifacts.kt new file mode 100644 index 00000000..6bbce781 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorArtifacts.kt @@ -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): List = 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 { + 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, + ), + ), + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt new file mode 100644 index 00000000..6fbeac3e --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorContext.kt @@ -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 { + 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 { + 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 { + 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>() + 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 { + 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 { + 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 { + 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, + stagePrompt: String, +): List { + 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 = emptySet(), + criticFromStage: String? = null, +): List { + 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, + ) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index 96fd7d31..99ea2394 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -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.* diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt new file mode 100644 index 00000000..7225214e --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt @@ -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, +): 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 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? { + 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): List> = + 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 { + 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. + */ diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt new file mode 100644 index 00000000..2facb11e --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt @@ -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, +): 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, + 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("; ") + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreview.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreview.kt new file mode 100644 index 00000000..ec2bc5f4 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreview.kt @@ -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, + 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, + 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? { + val argv = when (val raw = parameters["argv"]) { + is List<*> -> raw.filterIsInstance() + is String -> runCatching { Json.decodeFromString>(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, newLines: List): List { + 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() + 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, 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() + 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? { + 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? { + 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(")") + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorRepoContext.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorRepoContext.kt new file mode 100644 index 00000000..d3835161 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorRepoContext.kt @@ -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. + */ diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt new file mode 100644 index 00000000..a69f68ee --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorToolExec.kt @@ -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, + 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 { + 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() + 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, +): 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, + 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, + fileWrittenSlots: List, + 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), + ), + ) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt new file mode 100644 index 00000000..1295c77f --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkflow.kt @@ -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 = emptyMap(), + artifactContent: Map = 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) { + 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, + ) + }, + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt new file mode 100644 index 00000000..262dc8b9 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorWorkspace.kt @@ -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 { + 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, 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 = + 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() + 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