Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user