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
+2
View File
@@ -23,6 +23,8 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
- Config is read from disk; it is not event-sourced. Do not add event/state/reducer structures here.
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
## Verification
@@ -199,7 +199,7 @@ object ConfigLoader {
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
private const val DEFAULT_RETRIEVAL_K = 5
private const val DEFAULT_TOKEN_BUDGET = 4096
private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192
private const val DEFAULT_MODEL_CONTEXT_SIZE = 24_576
private const val DEFAULT_PROJECT_MEMORY_K = 5
private const val DEFAULT_PROJECT_MAX_DEPTH = 4
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
@@ -735,6 +735,14 @@ object ConfigLoader {
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
)
val gitSection = sections["git"] ?: emptyMap()
val git = GitConfig(
enabled = asBoolean(gitSection["enabled"], false),
remote = asString(gitSection["remote"], "origin"),
baseBranch = asString(gitSection["base_branch"], "main"),
author = asString(gitSection["author"], ""),
)
return CorrexConfig(
server = server,
tui = tui,
@@ -749,6 +757,7 @@ object ConfigLoader {
personalization = personalization,
orchestration = orchestration,
sampling = sampling,
git = git,
)
}
@@ -18,6 +18,21 @@ data class CorrexConfig(
val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
val sampling: SamplingConfig = SamplingConfig(),
val health: HealthConfig = HealthConfig(),
val git: GitConfig = GitConfig(),
)
/**
* Optional transport for a server-owned checkout. Each run executes on a `run/<sessionId>` branch
* based on [baseBranch] and pushes that branch on terminal completion; the working directory stays
* a real local path, never a remote URL or mounted client filesystem.
*/
@Serializable
data class GitConfig(
val enabled: Boolean = false,
val remote: String = "origin",
val baseBranch: String = "main",
/** Optional Git author value, for example `Correx <correx@example.invalid>`. */
val author: String = "",
)
/**
@@ -283,7 +298,8 @@ data class L3Config(
data class ModelConfig(
val id: String,
val modelPath: String,
val contextSize: Int = 8192,
/** Default window for implementation stages; leave headroom above their 24K prompt budget. */
val contextSize: Int = 24_576,
val params: Map<String, String> = emptyMap(),
val capabilities: Map<String, Double> = emptyMap(),
)
@@ -109,6 +109,12 @@ object CorrexConfigWriter {
cfg.sampling.minP?.let { b.kv("min_p", it) }
cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", it) }
b.section("git")
b.kv("enabled", cfg.git.enabled)
b.kv("remote", str(cfg.git.remote))
b.kv("base_branch", str(cfg.git.baseBranch))
b.kv("author", str(cfg.git.author))
b.section("personalization")
b.kv("enabled", cfg.personalization.enabled)
b.kv("learn", cfg.personalization.learn)
@@ -434,7 +434,7 @@ class ConfigLoaderTest {
assertEquals(1, result.models.size)
assertEquals("local-model", result.models[0].id)
assertEquals("/models/local.gguf", result.models[0].modelPath)
assertEquals(8192, result.models[0].contextSize)
assertEquals(24_576, result.models[0].contextSize)
}
@Test
@@ -37,6 +37,7 @@ class CorrexConfigWriterTest {
),
personalization = PersonalizationConfig(enabled = true, learn = true),
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
providers = listOf(
+2
View File
@@ -27,6 +27,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
- **SILENT FAILURE TRAP**: After adding any `EventPayload` subclass, immediately add it to `Serialization.kt` `eventModule` block. Run `./gradlew check` to verify. Tests may pass without it but runtime replay will fail silently.
- `AnyMapSerializer` — custom serializer for `Map<String, Any?>`; use it for dynamic payloads, don't roll another.
- Event classes are `@Serializable data class` with no mutable state. No methods beyond data accessors.
- `RunBranchPushedEvent` records an optional server Git transport push only after it succeeds; its branch/base/head SHAs are observations, not values replay recalculates.
- `RepoMapEntry.descriptor` is a bounded source-purpose observation recorded with the repo map and used when constructing semantic L3 embeddings.
- Do not add domain logic to events. They are records, not actors.
- `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection.
@@ -16,6 +16,8 @@ data class ContractAssertionResult(
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
val evaluator: String,
val passed: Boolean,
/** True when this assertion is intentionally deferred to another authoritative gate. */
val skipped: Boolean = false,
/** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
val evidence: String,
)
@@ -36,6 +36,19 @@ data class WorkflowFailedEvent(
val retryExhausted: Boolean,
) : EventPayload
/**
* Observation recorded only after the optional server Git transport has pushed a terminal run
* branch. This keeps the reviewable branch reference replayable without re-running Git.
*/
@Serializable
@SerialName("RunBranchPushed")
data class RunBranchPushedEvent(
val sessionId: SessionId,
val branch: String,
val baseSha: String,
val headSha: String,
) : EventPayload
/**
* Records that the operator approved access to a specific path OUTSIDE the workspace root
* (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace
@@ -11,6 +11,8 @@ data class RepoMapEntry(
val path: String,
val score: Double,
val symbols: List<String> = emptyList(),
/** Bounded deterministic source-purpose text used for semantic repo retrieval. */
val descriptor: String = "",
)
/**
@@ -89,6 +89,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.RunBranchPushedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent
@@ -151,6 +152,7 @@ val eventModule = SerializersModule {
subclass(OrchestrationResumedEvent::class)
subclass(OrchestrationPausedEvent::class)
subclass(WorkflowStartedEvent::class)
subclass(RunBranchPushedEvent::class)
subclass(WorkflowFailedEvent::class)
subclass(WorkflowCompletedEvent::class)
subclass(RetryAttemptedEvent::class)
+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",
)
+1
View File
@@ -27,6 +27,7 @@ CORREX kernel team.
- `DefaultTransitionResolver` is stateless per call — it reads `EvaluationContext`, not persisted state.
- Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip).
- `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session.
- `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists.
- Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
## Verification
@@ -52,7 +52,7 @@ data class StageConfig(
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
val semanticReview: Boolean = false,
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's
// terminal stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
// write-declaring plan stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
// file-writing stage as the generic `file_written` kind (never a typed code kind) and rarely sets
// build_expectation, so a plan that scaffolds real code would otherwise never hit an execution gate.
// When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the