Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -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.
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -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 }
|
||||
|
||||
+15
-1
@@ -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)
|
||||
|
||||
+1
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+44
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user