Implement closed-loop workspace follow-ups

This commit is contained in:
2026-07-15 23:48:12 +04:00
parent 3a48ecd24f
commit ed7efb6072
51 changed files with 702 additions and 54 deletions
+3
View File
@@ -20,6 +20,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
- Every stage receives a small curated operating-guidance system entry: verify observed state, create required project setup, and resolve necessary scope edges without re-deliberating.
- `JournalCompactionService` — triggers journal compaction and emits `JournalCompactedEvent`.
- `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring.
- `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning.
@@ -69,8 +69,15 @@ internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
"execution" to "file_write",
"contract" to "file_write",
"static_analysis" to "file_write",
"workspace_precondition" to "file_write",
)
internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance
Verify the current workspace before declaring completion. Read before modifying existing files.
When the authoritative intent plainly requires project setup, creating its manifest, configuration,
or entry file is in scope; do not spend turns re-deciding that settled boundary. Re-observe facts
instead of assuming them, and use the exact gate/tool feedback to make the smallest effective fix."""
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
internal fun ticketCategory(gate: String): String = when (gate) {
"plan_compile" -> "planning"
@@ -44,8 +44,9 @@ internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
*
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
* or null to fall through to the normal per-gate retry path. A stage that already holds the
* capability retries in place first; its unchanged-fingerprint retry exhaustion is separately
* routed from the step loop, because capability possession does not prove the owner can apply it.
*/
internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
@@ -56,7 +57,7 @@ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
): StepResult? {
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
if (requiredCapability in stageTools) return null // retry in place before no-progress exhaustion
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
}
@@ -269,6 +269,20 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
when (gateDecision) {
RetryDecision.Retry -> Unit // retry — loop and re-execute
RetryDecision.Exhausted -> {
// A stage may hold the nominal capability yet repeatedly fail to apply it
// (scope paralysis / frozen ReAct loop). Exhaustion is only returned for an
// unchanged fingerprint, so hand that no-progress dead-end to the
// intent-holder rather than declaring the workflow failed in place.
GATE_REQUIRED_CAPABILITY[result.gate]?.let { capability ->
routeToRecovery(
ctx,
stageId,
result.gate,
capability,
result.reason,
refreshedState,
)?.let { return it }
}
decideGateExhaustion(
ctx, stageId, result.gate, result.reason, refreshedState,
)?.let { return it }
@@ -81,6 +81,17 @@ internal suspend fun SessionOrchestrator.executeStage(
),
)
} ?: emptyList()
val operatingGuidance = listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = CURATED_STAGE_OPERATING_GUIDANCE,
sourceType = "operatingGuidance",
sourceId = "curated-v1",
tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE),
role = EntryRole.SYSTEM,
),
)
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
@@ -239,7 +250,7 @@ internal suspend fun SessionOrchestrator.executeStage(
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
?.let { listOf(it) } ?: emptyList()
var accumulatedEntries = stampBuckets(
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
systemPrompt + operatingGuidance + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries + claimedTaskEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
@@ -390,6 +401,9 @@ internal suspend fun SessionOrchestrator.executeStage(
inferenceResult.response.toolCalls, stageConfig, effectives,
approvalModeFor(session.state.boundProfile?.approvalMode),
inferenceResult.response.reasoning)
repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
return StageExecutionResult.Failure(reason, retryable = true, gate = "workspace_precondition")
}
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
if (fatalEntry != null) {
emitProcessResultEvents(sessionId, stageId, stageConfig)
@@ -258,6 +258,7 @@ internal suspend fun SessionOrchestrator.evaluateStageContract(
layer = a.layer.name,
evaluator = a.evaluator.name,
passed = v.passed,
skipped = v.skipped,
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
)
}
@@ -0,0 +1,44 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
/**
* Repeated attempts to read a missing build prerequisite are no longer mere ReAct noise. Once the
* same build-critical path has been durably blocked three times in one stage, return a retryable
* gate failure so the normal recovery ladder can bootstrap it (or request a different owner).
*/
internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
sessionId: SessionId,
stageId: StageId,
): String? {
val events = eventStore.read(sessionId)
val requests = events
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.associateBy { it.invocationId }
val paths = events
.mapNotNull { it.payload as? ToolCallAssessedEvent }
.filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
.filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
.mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String }
.filter(::isBuildCriticalPath)
val repeated = paths.groupingBy { it }.eachCount().entries
.firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT }
?: return null
return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " +
"(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
}
private fun isBuildCriticalPath(path: String): Boolean {
val name = path.substringAfterLast('/').lowercase()
return name in BUILD_PREREQUISITE_FILENAMES
}
private const val REFERENCE_EXISTS_REPEAT_LIMIT = 3
private val BUILD_PREREQUISITE_FILENAMES = setOf(
"package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
)