feat(orchestration): plan grounding, positive-pattern mining, stage-prompt audition rig
Vikunja #167/#168/#169. #167 PlanGrounder (infrastructure/workflow): build-prereq + touches-scope existence grounding, emits PlanGroundingEvaluatedEvent. Deterministic fold over recorded workspace/plan events (invariants #8/#9). #168 positive-pattern mining (SessionOrchestratorPlanPatterns): mine SuccessfulPlanShape from cross-session log — a locked plan whose own workflow later completed — and inject the closest-resembling plan shape as L0/SYSTEM advisory context for the planning stage. Keyword-Jaccard resemblance, no LLM/embedder, replays identically. Advisory only (#3). #169 stage-prompt audit + CORREX_STAGE_GUIDANCE ON/OFF toggle in SessionOrchestratorExecution. RunCommand gains --intent to seed a freestyle session over REST. Also: workspace verification events + concept-compiler wiring. ./gradlew check green.
This commit is contained in:
@@ -8,6 +8,41 @@
|
||||
- AGENTS.md files are binding work contracts for their subtrees
|
||||
- Work products, source materials, instructions, records, assets, and durable docs must stay understandable from the nearest applicable AGENTS.md plus every parent AGENTS.md above it
|
||||
|
||||
## Project Architecture
|
||||
|
||||
- Correx is a local-first, event-sourced orchestration kernel for LLM workflows, built with Kotlin/JVM 21 and Gradle.
|
||||
- The event log is the sole source of truth. State is rebuilt from events; projections are disposable and owned by their bounded context.
|
||||
- Keep deterministic core logic separate from nondeterministic inputs: LLM and tool outputs are untrusted proposals until validation; policy denials are terminal and cannot be overridden.
|
||||
- Record each nondeterministic environment observation (for example filesystem, network, retrieval, or clock data) as an event when observed. Replay and downstream logic must use recorded facts and make no external calls.
|
||||
- Tools must declare an execution tier and record every side effect as events. Compression and other derived representations are non-authoritative and cannot replace original events.
|
||||
- New `EventPayload` implementations must be registered in `core/events/.../serialization/Serialization.kt` in the `eventModule` polymorphic block; otherwise runtime deserialization can fail silently.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
- `core/` contains domain logic and may not depend on `infrastructure/` or `apps/`.
|
||||
- `infrastructure/` implements adapters against core contracts; `apps/` composes core and infrastructure into runnable processes.
|
||||
- Do not introduce circular dependencies or dependencies from a module to a sibling core module unless the module architecture explicitly establishes one. Shared event vocabulary belongs in `core:events`.
|
||||
- Inject dependencies rather than constructing concrete collaborators inside domain classes; use existing interfaces at boundaries.
|
||||
|
||||
## Kotlin Work Guidance
|
||||
|
||||
- Keep reducers limited to deterministic state transitions; do not put domain decisions or side effects in reducers.
|
||||
- Convert exceptions to sealed domain results at the earliest practical boundary. Do not use broad `try`/`catch` that silently returns a fallback.
|
||||
- Use coroutine-safe patterns: put blocking I/O in `Dispatchers.IO`, never use `Thread.sleep()`, and do not swallow `CancellationException`.
|
||||
- Before finalizing a Kotlin file, remove unused imports and verify direct imports only.
|
||||
|
||||
## Verification and Context
|
||||
|
||||
- Tests for production modules may live under `testing/`; search there before deciding a module has no tests.
|
||||
- Run focused tests with `./gradlew :path:to:module:test --rerun-tasks`. Run `./gradlew check` for the full test, Detekt, and Kover verification suite.
|
||||
- Detekt failures are enforced. Prefer correcting violations; use the narrowest suppression only for a genuine false positive.
|
||||
- Use `python scripts/ctx.py <query>` for ranked code context and `python scripts/ctx.py --deps <file>` for symbol dependency context when relevant.
|
||||
|
||||
## Task Tracking
|
||||
|
||||
- Use the Vikunja **Correx** project (`project_id: 4`) as the cross-session, user-visible backlog for follow-up work the user wants retained, such as deferred fixes, unverified QA gates, and designed-but-unimplemented work.
|
||||
- Use the available Vikunja integration to review, create, and close tasks. Keep each task self-contained with its root cause, chosen fix, and relevant file paths so it can be resumed without prior session context.
|
||||
|
||||
## Read Before Editing
|
||||
|
||||
1. Read the root AGENTS.md
|
||||
|
||||
@@ -29,7 +29,7 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@Serializable
|
||||
private data class StartSessionRequest(val workflowId: String, val sessionId: String?)
|
||||
private data class StartSessionRequest(val workflowId: String, val sessionId: String?, val intent: String? = null)
|
||||
|
||||
@Serializable
|
||||
private data class StartSessionResponse(val sessionId: String)
|
||||
@@ -60,6 +60,7 @@ class RunCommand : CliktCommand(name = "run") {
|
||||
private val workflow by option("--workflow", help = "Path to workflow definition").required()
|
||||
private val sessionId by option("--session", help = "Existing session ID to resume")
|
||||
private val autoApprove by option("--auto-approve", help = "Auto-approve all approval requests").flag()
|
||||
private val intent by option("--intent", help = "Freestyle intent to seed a new session")
|
||||
private val host by option("--host", help = "Server host").default("localhost")
|
||||
private val port by option("--port", help = "Server port").default("$DEFAULT_PORT")
|
||||
|
||||
@@ -108,7 +109,7 @@ class RunCommand : CliktCommand(name = "run") {
|
||||
): String? = runCatching {
|
||||
val resp = client.post("http://$host:$portInt/sessions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(StartSessionRequest(workflowId = resolveWorkflowId(workflow), sessionId = sessionId))
|
||||
setBody(StartSessionRequest(resolveWorkflowId(workflow), sessionId, intent))
|
||||
}
|
||||
resp.body<StartSessionResponse>().sessionId
|
||||
}.getOrElse { e ->
|
||||
|
||||
+10
-3
@@ -56,9 +56,12 @@ class ConceptCompilerService(
|
||||
payload = ConceptPromotedEvent(
|
||||
sessionId = SYSTEM_SESSION,
|
||||
fingerprint = cluster.fingerprint,
|
||||
classKey = cluster.classKey,
|
||||
gate = cluster.gate,
|
||||
conceptText = text,
|
||||
occurrences = cluster.validatedFixes,
|
||||
fixPath = cluster.fixPath,
|
||||
fixHash = cluster.fixHash,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -85,9 +88,13 @@ class ConceptCompilerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun conceptText(c: ConceptCluster) =
|
||||
"Recurring ${c.gate} failure (validated-fixed ${c.validatedFixes}× across sessions): " +
|
||||
"${c.signature}. This failure class has a known resolution — check prior fixes before re-deriving."
|
||||
private fun conceptText(c: ConceptCluster): String {
|
||||
val resolution = c.fixPath?.let { path ->
|
||||
" Validated resolution in `$path`" + (c.fixHash?.let { "@$it" } ?: "") + " — inspect it before re-deriving."
|
||||
} ?: " This failure class has a known resolution — check prior fixes before re-deriving."
|
||||
return "Recurring ${c.gate} failure (validated-fixed ${c.validatedFixes}× across sessions): " +
|
||||
"${c.signature}.$resolution"
|
||||
}
|
||||
|
||||
private fun metadata() = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
|
||||
@@ -7,7 +7,12 @@ import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.PlanGroundingEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanGroundingVerdict
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.infrastructure.workflow.PlanGrounder
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.infrastructure.workflow.CapabilityGap
|
||||
import com.correx.infrastructure.workflow.CapabilityGapDetector
|
||||
@@ -76,6 +81,11 @@ class FreestyleDriver(
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
return
|
||||
}
|
||||
// Plan grounding (design 2026-07-15 seam 1) before lock: check the compiled plan against the
|
||||
// session's recorded workspace facts (repo map + profile commands). A build stage aimed at a
|
||||
// prerequisite nothing creates is doomed — reject now so it fails at stage 0, not after
|
||||
// downstream files pile up. Deterministic + pure over recorded events (invariants #8/#9).
|
||||
if (groundPlan(sessionId, graph)) return
|
||||
// Capability-gap detector (Vikunja #30 part 1): advisory only — recorded, never blocking.
|
||||
// A gap does not fail the gate and does not grant the missing tool (invariants #3/#4/#5).
|
||||
val gaps = CapabilityGapDetector.detect(graph, toolCapabilities)
|
||||
@@ -132,6 +142,44 @@ class FreestyleDriver(
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Grounds [graph] against recorded workspace facts and emits [PlanGroundingEvaluatedEvent].
|
||||
* Returns true if the plan was rejected (caller must stop before lock). Missing repo map/profile
|
||||
* (fresh session, no scan) grounds vacuously — an empty path set with the manifest-produced-by-plan
|
||||
* check still catches the "build with nothing to build" case.
|
||||
*/
|
||||
private suspend fun groundPlan(sessionId: SessionId, graph: WorkflowGraph): Boolean {
|
||||
val events = eventStore.read(sessionId)
|
||||
val repoMap = events.mapNotNull { it.payload as? RepoMapComputedEvent }.lastOrNull()
|
||||
val profile = events.mapNotNull { it.payload as? ProjectProfileBoundEvent }.lastOrNull()
|
||||
val paths = repoMap?.entries?.map { it.path }?.toSet().orEmpty()
|
||||
val result = PlanGrounder.ground(graph, paths, profile?.commands.orEmpty())
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = PlanGroundingEvaluatedEvent(
|
||||
sessionId = sessionId,
|
||||
planId = graph.id,
|
||||
stateKey = repoMap?.stateKey.orEmpty(),
|
||||
verdict = result.verdict,
|
||||
findings = result.findings,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (result.verdict == PlanGroundingVerdict.PASS) return false
|
||||
val summary = result.findings.joinToString("; ")
|
||||
log.warn("freestyle: plan failed grounding for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed grounding: $summary", "grounding")
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun emitPlanLint(sessionId: SessionId, candidateId: String, lint: PlanLintResult) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
|
||||
@@ -22,12 +22,21 @@ data class ConceptPromotedEvent(
|
||||
// A dedicated system session (ConceptCompilerService.SYSTEM_SESSION) — concepts are cross-session,
|
||||
// so they live on their own stream rather than polluting any user session's replay.
|
||||
val sessionId: SessionId,
|
||||
// Gate-agnostic fingerprint of the recurring failure (see FailureFingerprint).
|
||||
// Gate-agnostic fingerprint of the recurring failure (see FailureFingerprint) — the last-seen
|
||||
// instance, used as the L3 instance-recall id.
|
||||
val fingerprint: String,
|
||||
// Reusable class key `gate + normalized(signature)` the concept is promoted on (design §"class key").
|
||||
// Defaulted for back-compat with v1 events that predate class-key promotion.
|
||||
val classKey: String = "",
|
||||
// The gate the failure recurred on ("contract" | "review" | "plan-compile" | "lint" | "static" | …).
|
||||
val gate: String,
|
||||
// Templated, retrieval-friendly concept text injected into L3.
|
||||
val conceptText: String,
|
||||
// Count of validated fixes observed at promotion time (≥ threshold).
|
||||
val occurrences: Int,
|
||||
// Reference to the validated fix: the CAS path + post-image hash of the file the resolving stage
|
||||
// wrote to clear this failure (design 2026-07-12-acr-concept-compiler.md §"carry the fix").
|
||||
// Null when no file write was observed between the retry and the completion. Additive/back-compat.
|
||||
val fixPath: String? = null,
|
||||
val fixHash: String? = null,
|
||||
) : EventPayload
|
||||
|
||||
@@ -74,6 +74,34 @@ data class CapabilityGapDetectedEvent(
|
||||
val timestampMs: Long,
|
||||
) : EventPayload
|
||||
|
||||
/** Verdict of the deterministic plan-grounding phase (design 2026-07-15 seam 1). */
|
||||
enum class PlanGroundingVerdict { PASS, RETURN_TO_ARCHITECT, CLARIFICATION_REQUIRED }
|
||||
|
||||
/**
|
||||
* The compiled plan was evaluated against recorded workspace facts BEFORE lock (design 2026-07-15
|
||||
* §"Seam 1: plan grounding before lock"). Deterministic, pure over the plan graph plus the session's
|
||||
* recorded [RepoMapComputedEvent] and [ProjectProfileBoundEvent] — no inference, no live FS read, so
|
||||
* replay reads the recorded verdict back (invariants #8/#9). A non-[PlanGroundingVerdict.PASS] verdict
|
||||
* blocks the lock and returns compact [findings] to the architect, so a doomed scaffold fails at stage
|
||||
* 0 rather than after downstream files accumulate.
|
||||
*
|
||||
* v1 grounds build prerequisites: a stage that will run a build/typecheck/test command against a
|
||||
* prerequisite (build manifest) that neither exists in the recorded repo map nor is produced by any
|
||||
* plan stage is ungrounded. Path/symbol-reference grounding (spec rows 2/3) needs a grounding-grade
|
||||
* symbol index and is deferred — those assumptions resolve to `unknown`, never `present`.
|
||||
*
|
||||
* [stateKey] pins the workspace snapshot (repo-map hash) the grounding was evaluated against.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("PlanGroundingEvaluated")
|
||||
data class PlanGroundingEvaluatedEvent(
|
||||
val sessionId: SessionId,
|
||||
val planId: String,
|
||||
val stateKey: String,
|
||||
val verdict: PlanGroundingVerdict,
|
||||
val findings: List<String> = emptyList(),
|
||||
) : EventPayload
|
||||
|
||||
/** Outcome of the bounded LLM "are you sure?" reflection pass over a [CapabilityGapDetectedEvent]. */
|
||||
enum class CapabilityGapVerdict { RESOLVED, NEEDS_TOOL }
|
||||
|
||||
|
||||
@@ -168,3 +168,23 @@ data class RetrySalvageDecidedEvent(
|
||||
val decision: SalvageDecision,
|
||||
val rationale: String,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* A stage repeatedly blocked on a missing build prerequisite (see repeatedBuildCriticalReferenceBlock,
|
||||
* design 2026-07-15 §#170) AND the stage both holds file_write and declares the path in-scope, so the
|
||||
* orchestrator grants it ONE dedicated bootstrap turn to create/repair that prerequisite — separately
|
||||
* budgeted, NOT charged against the normal stage retry counter. This marker records the grant: it caps
|
||||
* the bootstrap at one attempt per stage (a second block escalates to recovery) and scopes the
|
||||
* REFERENCE_EXISTS block window so historical blocks from before the grant don't re-trip the gate on
|
||||
* the fresh turn. Recorded (invariant #9) so replay reproduces the same bounded bootstrap.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("BuildPrerequisiteBootstrapAttempted")
|
||||
data class BuildPrerequisiteBootstrapAttemptedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
// The build-critical workspace-relative path the stage is granted a turn to create/repair.
|
||||
val path: String,
|
||||
// The gate evidence (repeatedBuildCriticalReferenceBlock reason) that triggered the grant.
|
||||
val evidence: String,
|
||||
) : EventPayload
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The known-good workspace invariant (design 2026-07-15 §"Known-good workspace invariant", seam 2).
|
||||
* After a code/config-writing stage runs its deterministic build/test gate, the orchestrator records
|
||||
* ONE verification observation binding the [passed] result to the [stateKey] of the workspace that was
|
||||
* actually verified. The next implementation stage may treat the workspace as `known-good` only when
|
||||
* a passing observation's [stateKey] still equals the current recorded state key — a later write
|
||||
* changes the key, so a dirty workspace can never be mistaken for the state that passed.
|
||||
*
|
||||
* The [stateKey] is derived purely from the recorded FileWritten manifest (path → latest post-image
|
||||
* hash), so it is replay-safe: replay reads this event and the key back, never re-running [command]
|
||||
* or rescanning the filesystem (invariants #8/#9). [changedPaths] are the write-declaring stage's
|
||||
* paths at verification time; [expectation] is the deterministic build vocabulary (MODULE/PROJECT/
|
||||
* TESTS) that selected [command] — the model never chooses the truth test.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("WorkspaceVerificationObserved")
|
||||
data class WorkspaceVerificationObservedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val stateKey: String,
|
||||
val expectation: String,
|
||||
val command: String,
|
||||
val passed: Boolean,
|
||||
val changedPaths: List<String> = emptyList(),
|
||||
) : EventPayload
|
||||
@@ -65,6 +65,9 @@ import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.RetrySalvageDecidedEvent
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
|
||||
import com.correx.core.events.events.WorkspaceVerificationObservedEvent
|
||||
import com.correx.core.events.events.PlanGroundingEvaluatedEvent
|
||||
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
|
||||
import com.correx.core.events.events.WorkspaceStateObservedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
@@ -158,6 +161,9 @@ val eventModule = SerializersModule {
|
||||
subclass(RetryAttemptedEvent::class)
|
||||
subclass(RetrySalvageDecidedEvent::class)
|
||||
subclass(FailureTicketOpenedEvent::class)
|
||||
subclass(BuildPrerequisiteBootstrapAttemptedEvent::class)
|
||||
subclass(WorkspaceVerificationObservedEvent::class)
|
||||
subclass(PlanGroundingEvaluatedEvent::class)
|
||||
subclass(OutsidePathAccessGrantedEvent::class)
|
||||
subclass(RefinementIterationEvent::class)
|
||||
subclass(RepoMapComputedEvent::class)
|
||||
|
||||
+60
-13
@@ -1,6 +1,7 @@
|
||||
package com.correx.core.kernel.concept
|
||||
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
@@ -11,28 +12,59 @@ import com.correx.core.sessions.projections.Projection
|
||||
/** Default validated-fix count a fingerprint must reach before promotion (design §"Promotion rule"). */
|
||||
const val DEFAULT_PROMOTION_THRESHOLD = 3
|
||||
|
||||
/** A recurring failure signature and its resolution tally, keyed by [RetryAttemptedEvent.fingerprint]. */
|
||||
/**
|
||||
* A recurring failure and its resolution tally, keyed by [classKey] (design 2026-07-12
|
||||
* §"Promote to a class key"). The class key is `gate + normalized(signature)`, so near-identical
|
||||
* failures that differ only in volatile tokens (paths, line numbers, hashes) aggregate into one
|
||||
* reusable concept instead of a fresh cluster per exact fingerprint. The exact [fingerprint] is
|
||||
* retained for the last-seen instance (retrieval-friendly, and the L3 instance-recall id).
|
||||
*/
|
||||
data class ConceptCluster(
|
||||
val classKey: String,
|
||||
val fingerprint: String,
|
||||
val gate: String,
|
||||
// First line of the recurring failure reason — the retrieval-friendly signature.
|
||||
val signature: String,
|
||||
// Count of times a stage retrying this fingerprint went on to complete = validated failure→fix.
|
||||
// Count of times a stage retrying this class went on to complete = validated failure→fix.
|
||||
val validatedFixes: Int = 0,
|
||||
// Any StageFailed / WorkflowFailed observed while this fingerprint was open: the fix did NOT hold.
|
||||
// Any StageFailed / WorkflowFailed observed while this class was open: the fix did NOT hold.
|
||||
val contradicted: Boolean = false,
|
||||
// The validated fix: CAS path + post-image hash of the last file the resolving stage wrote before
|
||||
// completing. Null until a fix→complete pair carries a file write. First captured ref wins.
|
||||
val fixPath: String? = null,
|
||||
val fixHash: String? = null,
|
||||
)
|
||||
|
||||
/** A fingerprint currently being fought by a stage — resolved by the next StageCompleted/Failed. */
|
||||
data class OpenFailure(val fingerprint: String, val gate: String, val signature: String)
|
||||
/** A failure currently being fought by a stage — resolved by the next StageCompleted/Failed. */
|
||||
data class OpenFailure(val classKey: String, val fingerprint: String, val gate: String, val signature: String)
|
||||
|
||||
/**
|
||||
* Normalize a failure signature into a reusable class dimension: lowercase, collapse digit runs and
|
||||
* quoted/pathish tokens to placeholders so `src/App.tsx:12` and `src/Nav.tsx:88` map to one class.
|
||||
* Deterministic (replay-safe) — pure string transform, no environment read.
|
||||
*/
|
||||
fun conceptClassKey(gate: String, signature: String): String {
|
||||
val normalized = signature.lowercase()
|
||||
.replace(Regex("""['"`][^'"`]*['"`]"""), "<str>")
|
||||
.replace(Regex("""\b[\w./\\-]+\.[a-z0-9]{1,5}\b"""), "<path>")
|
||||
.replace(Regex("""\d+"""), "#")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
return "$gate:$normalized"
|
||||
}
|
||||
|
||||
data class ConceptCompilerState(
|
||||
// Keyed by classKey (design §"Promote to a class key").
|
||||
val clusters: Map<String, ConceptCluster> = emptyMap(),
|
||||
// fingerprints already emitted as ConceptPromotedEvent — folded back so replay never re-promotes.
|
||||
// classKeys already emitted as ConceptPromotedEvent — folded back so replay never re-promotes.
|
||||
val promoted: Set<String> = emptySet(),
|
||||
// Per-stage open failure the stage is retrying; keyed by "sessionId/stageId" so concurrent sessions
|
||||
// in one cross-session fold don't collide.
|
||||
val open: Map<String, OpenFailure> = emptyMap(),
|
||||
// Last file write observed per session — the candidate validated-fix ref attached when the session's
|
||||
// open failure resolves as fixed. Keyed by sessionId; execution is sequential so one stage writes at
|
||||
// a time. Cleared on resolve so a later stage's writes don't back-attribute to an earlier fix.
|
||||
val lastWrite: Map<String, Pair<String, String>> = emptyMap(),
|
||||
) {
|
||||
/**
|
||||
* Clusters that have earned promotion but haven't been emitted yet: validated ≥ [threshold], never
|
||||
@@ -40,7 +72,7 @@ data class ConceptCompilerState(
|
||||
*/
|
||||
fun promotable(threshold: Int = DEFAULT_PROMOTION_THRESHOLD): List<ConceptCluster> =
|
||||
clusters.values.filter {
|
||||
it.fingerprint !in promoted && !it.contradicted && it.validatedFixes >= threshold
|
||||
it.classKey !in promoted && !it.contradicted && it.validatedFixes >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,27 +94,42 @@ class ConceptCompilerProjection : Projection<ConceptCompilerState> {
|
||||
is RetryAttemptedEvent -> {
|
||||
val key = "${p.sessionId.value}/${p.stageId.value}"
|
||||
val sig = p.failureReason.lineSequence().firstOrNull()?.take(SIGNATURE_MAX)?.trim().orEmpty()
|
||||
state.copy(open = state.open + (key to OpenFailure(p.fingerprint, p.gate, sig)))
|
||||
val classKey = conceptClassKey(p.gate, sig)
|
||||
state.copy(open = state.open + (key to OpenFailure(classKey, p.fingerprint, p.gate, sig)))
|
||||
}
|
||||
is FileWrittenEvent -> p.postImageHash?.let {
|
||||
state.copy(lastWrite = state.lastWrite + (p.sessionId.value to (p.path to it)))
|
||||
} ?: state
|
||||
is StageCompletedEvent -> resolve(state, "${p.sessionId.value}/${p.stageId.value}", fixed = true)
|
||||
is StageFailedEvent -> resolve(state, "${p.sessionId.value}/${p.stageId.value}", fixed = false)
|
||||
is WorkflowFailedEvent -> failAllInSession(state, p.sessionId.value)
|
||||
is ConceptPromotedEvent -> state.copy(promoted = state.promoted + p.fingerprint)
|
||||
is ConceptPromotedEvent -> state.copy(promoted = state.promoted + p.classKey)
|
||||
else -> state
|
||||
}
|
||||
|
||||
private fun resolve(state: ConceptCompilerState, key: String, fixed: Boolean): ConceptCompilerState {
|
||||
val failure = state.open[key] ?: return state
|
||||
val existing = state.clusters[failure.fingerprint]
|
||||
?: ConceptCluster(failure.fingerprint, failure.gate, failure.signature)
|
||||
val sessionId = key.substringBefore('/')
|
||||
val existing = state.clusters[failure.classKey]
|
||||
?: ConceptCluster(failure.classKey, failure.fingerprint, failure.gate, failure.signature)
|
||||
val updated = if (fixed) {
|
||||
existing.copy(validatedFixes = existing.validatedFixes + 1)
|
||||
val fix = state.lastWrite[sessionId]
|
||||
existing.copy(
|
||||
// Keep the latest instance's fingerprint/signature for recall.
|
||||
fingerprint = failure.fingerprint,
|
||||
signature = failure.signature,
|
||||
validatedFixes = existing.validatedFixes + 1,
|
||||
// First captured ref wins — keep the earliest validated fix as the canonical resolution.
|
||||
fixPath = existing.fixPath ?: fix?.first,
|
||||
fixHash = existing.fixHash ?: fix?.second,
|
||||
)
|
||||
} else {
|
||||
existing.copy(contradicted = true)
|
||||
}
|
||||
return state.copy(
|
||||
clusters = state.clusters + (failure.fingerprint to updated),
|
||||
clusters = state.clusters + (failure.classKey to updated),
|
||||
open = state.open - key,
|
||||
lastWrite = state.lastWrite - sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -65,11 +65,15 @@ internal val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
|
||||
|
||||
// Deterministic gate id → capability the failing stage must hold to change the failure condition.
|
||||
// A gate not listed here is not eligible for recovery routing (retries in place as before).
|
||||
// Gate id for a repeated missing build prerequisite (design #163/#170). Its own separate handling
|
||||
// (bounded bootstrap turn / recovery routing) is triggered off this id before the normal retry path.
|
||||
internal const val WORKSPACE_PRECONDITION_GATE = "workspace_precondition"
|
||||
|
||||
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",
|
||||
WORKSPACE_PRECONDITION_GATE to "file_write",
|
||||
)
|
||||
|
||||
internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance
|
||||
|
||||
+10
@@ -254,6 +254,16 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
|
||||
}
|
||||
val refreshedState = orchestrationRepository.getState(ctx.sessionId)
|
||||
// A repeated missing-build-prerequisite block is not an ordinary retry: it takes a
|
||||
// bounded, separately-budgeted precondition-resolution path (design #170) that never
|
||||
// charges the stage retry counter. FallThrough = defer to the normal recovery routing.
|
||||
if (result.gate == WORKSPACE_PRECONDITION_GATE) {
|
||||
when (val outcome = buildPrerequisiteDecision(ctx, stageId, result, refreshedState)) {
|
||||
PrerequisiteOutcome.Bootstrap -> continue // re-run stage, no retry charged
|
||||
PrerequisiteOutcome.FallThrough -> Unit // non-writable → recovery routing below
|
||||
is PrerequisiteOutcome.Done -> return outcome.result
|
||||
}
|
||||
}
|
||||
// Retry-agency invariant: a gate this stage lacks the capability to fix must not
|
||||
// be retried in place (futile). Route to a recovery stage that holds the
|
||||
// capability, if one exists and the route budget remains.
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
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.ConceptPromotedEvent
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.kernel.concept.ConceptCompilerProjection
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Deterministic delivery of promoted capability-accretion concepts into a stage's context
|
||||
* (design 2026-07-12-acr-concept-compiler.md §"Delivery" — replaces the L3 top-k lottery as the
|
||||
* PRIMARY channel; L3 remains an instance-recall fallback). A [ConceptPromotedEvent] is the
|
||||
* authoritative record of a validated failure→fix class; injecting it as an L0/system block tells a
|
||||
* stage about a known resolution BEFORE it re-derives the same fix.
|
||||
*
|
||||
* Relevance filter (avoids the broadcast the design's verdict #1 rejected): only inject a concept a
|
||||
* stage could actually act on — a concept whose gate requires a capability ([GATE_REQUIRED_CAPABILITY])
|
||||
* the stage does not hold is skipped (no point telling a write-less verifier about a file_write fix).
|
||||
* Gates with no required capability (lint/review) are broadly relevant. Bounded to [limit] by
|
||||
* occurrences so context stays small. Pure over the promoted set → same input, same injection.
|
||||
*/
|
||||
internal fun selectPromotedConcepts(
|
||||
promoted: List<ConceptPromotedEvent>,
|
||||
allowedTools: Set<String>,
|
||||
contradicted: Set<String> = emptySet(),
|
||||
limit: Int = MAX_PROMOTED_CONCEPTS,
|
||||
): List<ConceptPromotedEvent> =
|
||||
promoted
|
||||
// Honor a contradiction observed AFTER promotion (design 2026-07-12 §"Decay/contradiction"):
|
||||
// a concept whose class later failed to hold must not keep being delivered as a known fix.
|
||||
.filter { it.classKey.ifBlank { it.fingerprint } !in contradicted }
|
||||
.filter { concept -> GATE_REQUIRED_CAPABILITY[concept.gate]?.let { it in allowedTools } ?: true }
|
||||
.distinctBy { it.classKey.ifBlank { it.fingerprint } }
|
||||
.sortedByDescending { it.occurrences }
|
||||
.take(limit)
|
||||
|
||||
/** L0/system context entries for the concepts relevant to [stageConfig], read from the whole log. */
|
||||
internal suspend fun SessionOrchestrator.promotedConceptEntries(stageConfig: StageConfig): List<ContextEntry> {
|
||||
val events = eventStore.allEvents().toList()
|
||||
val promoted = events.mapNotNull { it.payload as? ConceptPromotedEvent }
|
||||
if (promoted.isEmpty()) return emptyList()
|
||||
// Fold the same projection the compiler uses to find classes contradicted since promotion.
|
||||
val projection = ConceptCompilerProjection()
|
||||
val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) }
|
||||
val contradicted = state.clusters.values.filter { it.contradicted }.map { it.classKey }.toSet()
|
||||
return selectPromotedConcepts(promoted, stageConfig.effectiveAllowedTools, contradicted).map { concept ->
|
||||
// Deliver the FIX, not just a nag (design §"carry the fix"): when the accreted concept carries a
|
||||
// validated-fix ref, point the stage at the file that resolved this class before, so it applies a
|
||||
// known resolution instead of re-deriving one.
|
||||
val fixPointer = concept.fixPath?.let { " A prior validated fix modified: $it — mirror that resolution." }
|
||||
?: ""
|
||||
val content = "Known ${concept.gate} failure class (accreted from prior validated runs). " +
|
||||
concept.conceptText + fixPointer
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "promotedConcept",
|
||||
sourceId = concept.classKey.ifBlank { concept.fingerprint },
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_PROMOTED_CONCEPTS = 3
|
||||
+16
-3
@@ -81,7 +81,10 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
val operatingGuidance = listOf(
|
||||
// ponytail: env toggle so the prompt-audition harness (scripts/prompt-audition.sh, task #169) can
|
||||
// A/B the curated guidance block against a trimmed baseline without a rebuild. CORREX_STAGE_GUIDANCE=off
|
||||
// drops it; anything else (default) keeps it. Promote to a real config knob only if this outlives the audit.
|
||||
val operatingGuidance = if (System.getenv("CORREX_STAGE_GUIDANCE") == "off") emptyList() else listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
@@ -92,6 +95,16 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
// Capability accretion (design 2026-07-12-acr-concept-compiler.md): deterministically inject the
|
||||
// validated failure→fix concepts this stage could act on, so a known resolution is delivered
|
||||
// BEFORE the stage re-derives it. Primary channel; L3 remains the instance-recall fallback.
|
||||
val promotedConcepts = promotedConceptEntries(stageConfig)
|
||||
// Positive-pattern accretion (design 2026-07-12 §"broaden beyond failure→fix"): show the architect a
|
||||
// prior successful plan shape for a similar intent so it plans from a validated decomposition, not zero.
|
||||
val successfulPlanShapes = successfulPlanShapeEntries(sessionId, stageConfig)
|
||||
// Known-good workspace invariant (design 2026-07-15 seam 2): tell the stage whether the last
|
||||
// green build is still valid for the current workspace state, or stale and needing re-verification.
|
||||
val verifiedBaseline = verifiedBaselineEntries(sessionId)
|
||||
// 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
|
||||
@@ -250,7 +263,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + operatingGuidance + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
systemPrompt + operatingGuidance + promotedConcepts + successfulPlanShapes + verifiedBaseline + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||
@@ -402,7 +415,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
approvalModeFor(session.state.boundProfile?.approvalMode),
|
||||
inferenceResult.response.reasoning)
|
||||
repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
|
||||
return StageExecutionResult.Failure(reason, retryable = true, gate = "workspace_precondition")
|
||||
return StageExecutionResult.Failure(reason, retryable = true, gate = WORKSPACE_PRECONDITION_GATE)
|
||||
}
|
||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||
if (fatalEntry != null) {
|
||||
|
||||
+6
-1
@@ -112,7 +112,12 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
|
||||
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())
|
||||
if (finding.clean) {
|
||||
// Known-good workspace invariant (design 2026-07-15 seam 2): bind this green result to the
|
||||
// workspace state key so the next stage can trust it only while the workspace is unchanged.
|
||||
recordWorkspaceVerification(sessionId, stageId, expectation, command, passed = true)
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
@file:Suppress("MatchingDeclarationName") // SessionOrchestrator* extension group, not a single-class file.
|
||||
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
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.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Positive-pattern accretion (design 2026-07-12-acr-concept-compiler.md §"Broaden beyond failure→fix"):
|
||||
* the failure→fix rail learns from what BROKE; this learns from what WORKED. When the architect is about
|
||||
* to plan, it is shown the plan SHAPE (stage-id sequence) of a prior run whose intent resembled this
|
||||
* one and that actually completed — so run N+1 starts from a decomposition already validated by run N,
|
||||
* instead of re-deriving structure from zero every time.
|
||||
*
|
||||
* Fully deterministic (invariants #8/#9): a pure fold over recorded events (initial intent, locked plan,
|
||||
* workflow completion) across all sessions. Intent resemblance is a keyword Jaccard overlap — no LLM, no
|
||||
* embedder — so it replays identically. Advisory only: it injects a reference shape as SYSTEM context;
|
||||
* the architect still authors the plan (invariant #3 LLM-proposes/core-decides).
|
||||
*/
|
||||
|
||||
/** A completed run reduced to what a future planner can reuse: its intent keywords and plan shape. */
|
||||
internal data class SuccessfulPlanShape(
|
||||
val sessionId: String,
|
||||
val intentKeywords: Set<String>,
|
||||
val stageSequence: List<String>,
|
||||
)
|
||||
|
||||
/** Stopword-trimmed lowercase word set of an intent — the deterministic resemblance key. */
|
||||
internal fun intentKeywords(intent: String): Set<String> =
|
||||
intent.lowercase()
|
||||
.split(Regex("""[^a-z0-9]+"""))
|
||||
.filter { it.length > 2 && it !in INTENT_STOPWORDS }
|
||||
.toSet()
|
||||
|
||||
/** Jaccard overlap of two keyword sets — 0.0 when either is empty. */
|
||||
internal fun keywordOverlap(a: Set<String>, b: Set<String>): Double {
|
||||
if (a.isEmpty() || b.isEmpty()) return 0.0
|
||||
val inter = a.intersect(b).size.toDouble()
|
||||
return inter / a.union(b).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine successful plan shapes from [events] (cross-session log). A session qualifies when its locked
|
||||
* plan's workflow later emits a [WorkflowCompletedEvent] for that SAME workflow id — i.e. the executed
|
||||
* plan (not just the planning phase) reached completion. [exclude] drops the current session so a run
|
||||
* never learns from itself.
|
||||
*/
|
||||
internal fun mineSuccessfulPlanShapes(events: List<StoredEvent>, exclude: String): List<SuccessfulPlanShape> {
|
||||
val intents = events.mapNotNull { it.payload as? InitialIntentEvent }.associate { it.sessionId.value to it.intent }
|
||||
val locked = events.mapNotNull { it.payload as? ExecutionPlanLockedEvent }
|
||||
val completedWorkflows = events.mapNotNull { it.payload as? WorkflowCompletedEvent }
|
||||
.map { it.sessionId.value to it.workflowId }.toSet()
|
||||
return locked.mapNotNull { plan ->
|
||||
val sid = plan.sessionId.value
|
||||
val intent = intents[sid] ?: return@mapNotNull null
|
||||
// The executed plan completed only if a completion for its OWN workflow id was recorded.
|
||||
if (sid == exclude || (sid to plan.workflowId) !in completedWorkflows) return@mapNotNull null
|
||||
SuccessfulPlanShape(sid, intentKeywords(intent), plan.stageIds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L0/SYSTEM entry naming the closest matching prior successful plan shape — but only for the PLANNING
|
||||
* stage (the one producing an `execution_plan`), and only when resemblance clears [MIN_INTENT_OVERLAP]
|
||||
* so an unrelated run's shape is not mistaken for guidance.
|
||||
*/
|
||||
internal suspend fun SessionOrchestrator.successfulPlanShapeEntries(
|
||||
sessionId: SessionId,
|
||||
stageConfig: StageConfig,
|
||||
): List<ContextEntry> {
|
||||
val isPlanningStage = stageConfig.produces.any { it.kind.id == "execution_plan" }
|
||||
if (!isPlanningStage) return emptyList()
|
||||
val here = initialIntent(sessionId)?.let(::intentKeywords).orEmpty()
|
||||
if (here.isEmpty()) return emptyList()
|
||||
val best = mineSuccessfulPlanShapes(eventStore.allEvents().toList(), exclude = sessionId.value)
|
||||
.map { it to keywordOverlap(here, it.intentKeywords) }
|
||||
.filter { it.second >= MIN_INTENT_OVERLAP }
|
||||
.maxByOrNull { it.second } ?: return emptyList()
|
||||
val content = "## A plan shape that worked before\nA prior run with a similar goal completed " +
|
||||
"successfully using this stage sequence — reuse its structure where it fits, adapt where the " +
|
||||
"goal differs:\n${best.first.stageSequence.joinToString(" → ")}"
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "successfulPlanShape",
|
||||
sourceId = best.first.sessionId,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private const val MIN_INTENT_OVERLAP = 0.34
|
||||
private val INTENT_STOPWORDS = setOf(
|
||||
"the", "and", "for", "with", "that", "this", "add", "make", "use", "using", "into", "from", "all",
|
||||
"new", "app", "should", "must", "will", "can", "its", "was", "are", "you", "your", "then",
|
||||
)
|
||||
+195
-4
@@ -1,26 +1,52 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
|
||||
import com.correx.core.events.events.ClarificationQuestion
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* same build-critical path has been durably BLOCK'd three times *within the current bootstrap
|
||||
* window* (design 2026-07-15 §#163/#170), return a retryable gate failure so the orchestrator can
|
||||
* grant a bounded precondition-resolution turn (or route to a stage that can create the file).
|
||||
*
|
||||
* The window is scoped to events after the last [BuildPrerequisiteBootstrapAttemptedEvent] for this
|
||||
* stage: without that, the historical blocks that triggered the FIRST detection would re-trip the
|
||||
* gate on the very first round of the granted bootstrap turn, before the model ever gets to write
|
||||
* the file. Scoping resets the count so the bootstrap turn starts clean and only *new* blocks
|
||||
* (the model failed to create it) escalate.
|
||||
*/
|
||||
internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
): String? {
|
||||
val events = eventStore.read(sessionId)
|
||||
val requests = events
|
||||
val windowStart = events
|
||||
.filter { (it.payload as? BuildPrerequisiteBootstrapAttemptedEvent)?.stageId == stageId }
|
||||
.maxOfOrNull { it.sequence } ?: Long.MIN_VALUE
|
||||
val windowed = events.filter { it.sequence > windowStart }
|
||||
val requests = windowed
|
||||
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId }
|
||||
.associateBy { it.invocationId }
|
||||
val paths = events
|
||||
val paths = windowed
|
||||
.mapNotNull { it.payload as? ToolCallAssessedEvent }
|
||||
.filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
|
||||
.filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
|
||||
@@ -33,11 +59,176 @@ internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
|
||||
"(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
|
||||
}
|
||||
|
||||
/**
|
||||
* Which bounded precondition-resolution path a repeated build-prerequisite block takes (design #170).
|
||||
* Pure over the three observable facts — deterministic/replay-safe, unit-tested in isolation.
|
||||
*/
|
||||
internal enum class PrerequisiteAction { BOOTSTRAP, ROUTE_TO_RECOVERY, CLARIFY, ESCALATE }
|
||||
|
||||
/**
|
||||
* - not writable → the stage can't create the file; route to a stage that can ([ROUTE_TO_RECOVERY]).
|
||||
* - writable but the path is outside the stage's declared scope → don't guess who owns project
|
||||
* setup, ask ([CLARIFY]).
|
||||
* - writable, in-scope, bootstrap budget left → grant the one dedicated turn ([BOOTSTRAP]).
|
||||
* - writable, in-scope, budget spent → the turn didn't clear it; escalate to recovery ([ESCALATE]).
|
||||
*/
|
||||
internal fun decidePrerequisiteAction(
|
||||
writable: Boolean,
|
||||
inScope: Boolean,
|
||||
bootstrapBudgetLeft: Boolean,
|
||||
): PrerequisiteAction = when {
|
||||
!writable -> PrerequisiteAction.ROUTE_TO_RECOVERY
|
||||
!inScope -> PrerequisiteAction.CLARIFY
|
||||
bootstrapBudgetLeft -> PrerequisiteAction.BOOTSTRAP
|
||||
else -> PrerequisiteAction.ESCALATE
|
||||
}
|
||||
|
||||
/** Extract the quoted build-prerequisite path from a [repeatedBuildCriticalReferenceBlock] reason. */
|
||||
internal fun buildPrerequisitePath(reason: String): String? =
|
||||
PREREQUISITE_PATH_RE.find(reason)?.groupValues?.get(1)
|
||||
|
||||
/**
|
||||
* Is [path] within this stage's declared territory? A stage with no declared scope (empty [touches]
|
||||
* and [writeManifest]) is unconstrained → owns everything → in-scope. Otherwise the path must match a
|
||||
* declared glob or be a concretely-declared file. Reused glob semantics from ManifestContainmentRule.
|
||||
*/
|
||||
internal fun pathInDeclaredScope(stage: StageConfig, path: String): Boolean {
|
||||
val globs = stage.touches + stage.writeManifest
|
||||
return globs.isEmpty() || // unconstrained stage owns everything
|
||||
path in stage.expectedFiles ||
|
||||
globs.any { FileSystems.getDefault().getPathMatcher("glob:$it").matches(Path.of(path)) }
|
||||
}
|
||||
|
||||
/** How many bootstrap turns this stage has already been granted (the separate one-shot budget). */
|
||||
internal fun bootstrapAttemptCount(events: List<StoredEvent>, stageId: StageId): Int =
|
||||
events.count { (it.payload as? BuildPrerequisiteBootstrapAttemptedEvent)?.stageId == stageId }
|
||||
|
||||
/**
|
||||
* What the step loop should do after a repeated build-prerequisite block on a writable-or-not stage.
|
||||
* [FallThrough] = not our special case (non-writable) — let the normal recovery routing handle it;
|
||||
* [Bootstrap] = re-run the stage this once (loop), the separate budget already charged;
|
||||
* [Done] = a terminal/routed [StepResult] to return.
|
||||
*/
|
||||
internal sealed interface PrerequisiteOutcome {
|
||||
data object Bootstrap : PrerequisiteOutcome
|
||||
data object FallThrough : PrerequisiteOutcome
|
||||
data class Done(val result: StepResult) : PrerequisiteOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide and enact the bounded precondition-resolution path for a `workspace_precondition` failure,
|
||||
* WITHOUT touching the normal stage retry budget (design #170). Side effects (recording the bootstrap
|
||||
* grant, requesting clarification, routing to recovery) are performed here; the returned outcome tells
|
||||
* the step loop whether to loop, fall through, or return.
|
||||
*/
|
||||
internal suspend fun DefaultSessionOrchestrator.buildPrerequisiteDecision(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
failure: com.correx.core.transitions.execution.StageExecutionResult.Failure,
|
||||
state: OrchestrationState,
|
||||
): PrerequisiteOutcome {
|
||||
val stage = ctx.graph.stages[stageId]
|
||||
val path = buildPrerequisitePath(failure.reason)
|
||||
if (stage == null || path == null) return PrerequisiteOutcome.FallThrough
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val action = decidePrerequisiteAction(
|
||||
writable = "file_write" in stage.allowedTools,
|
||||
inScope = pathInDeclaredScope(stage, path),
|
||||
bootstrapBudgetLeft = bootstrapAttemptCount(events, stageId) < BOOTSTRAP_BUDGET,
|
||||
)
|
||||
return when (action) {
|
||||
PrerequisiteAction.ROUTE_TO_RECOVERY -> PrerequisiteOutcome.FallThrough
|
||||
PrerequisiteAction.BOOTSTRAP -> {
|
||||
emit(
|
||||
ctx.sessionId,
|
||||
BuildPrerequisiteBootstrapAttemptedEvent(ctx.sessionId, stageId, path, failure.reason),
|
||||
)
|
||||
log.info(
|
||||
"[Orchestrator] granting build-prerequisite bootstrap turn session={} stage={} path={}",
|
||||
ctx.sessionId.value, stageId.value, path,
|
||||
)
|
||||
PrerequisiteOutcome.Bootstrap
|
||||
}
|
||||
PrerequisiteAction.CLARIFY -> requestBuildPrerequisiteClarification(ctx, stageId, path)
|
||||
PrerequisiteAction.ESCALATE -> PrerequisiteOutcome.Done(
|
||||
routeToRecovery(ctx, stageId, failure.gate, "file_write", failure.reason, state)
|
||||
?: StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
"build prerequisite '$path' unresolved after bootstrap: ${failure.reason}",
|
||||
retryExhausted = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The missing prerequisite lies outside the writable stage's declared scope: ownership of project
|
||||
* setup is ambiguous, so record a clarification request and pause rather than guess (design #170
|
||||
* point 5). After the operator answers, loop to re-run the stage with the answer in context. Bounded
|
||||
* by [tuning].maxClarificationRounds; a spent budget fails the workflow rather than looping.
|
||||
*/
|
||||
private suspend fun DefaultSessionOrchestrator.requestBuildPrerequisiteClarification(
|
||||
ctx: EnrichedExecutionContext,
|
||||
stageId: StageId,
|
||||
path: String,
|
||||
): PrerequisiteOutcome {
|
||||
val priorRounds = repositories.eventStore.read(ctx.sessionId)
|
||||
.count { (it.payload as? ClarificationRequestedEvent)?.stageId == stageId }
|
||||
if (priorRounds >= tuning.maxClarificationRounds) {
|
||||
return PrerequisiteOutcome.Done(
|
||||
StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
"build prerequisite '$path' is outside stage ${stageId.value}'s declared scope " +
|
||||
"and clarification budget is spent",
|
||||
retryExhausted = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val requestId = ClarificationRequestId(UUID.randomUUID().toString())
|
||||
val deferred = CompletableDeferred<List<ClarificationAnswer>>()
|
||||
pendingClarifications[requestId] = deferred
|
||||
emit(ctx.sessionId, OrchestrationPausedEvent(ctx.sessionId, stageId, "CLARIFICATION_PENDING"))
|
||||
emit(
|
||||
ctx.sessionId,
|
||||
ClarificationRequestedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = stageId,
|
||||
questions = listOf(
|
||||
ClarificationQuestion(
|
||||
id = "build_prerequisite",
|
||||
prompt = "Stage ${stageId.value} is blocked on a missing build prerequisite " +
|
||||
"'$path' that is outside its declared scope. Should this stage create it, or " +
|
||||
"does another stage own project setup?",
|
||||
header = "Missing setup",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return try {
|
||||
val answers = deferred.await()
|
||||
emit(ctx.sessionId, ClarificationAnsweredEvent(requestId, ctx.sessionId, stageId, answers))
|
||||
emit(ctx.sessionId, OrchestrationResumedEvent(ctx.sessionId, stageId))
|
||||
PrerequisiteOutcome.Bootstrap
|
||||
} finally {
|
||||
pendingClarifications.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBuildCriticalPath(path: String): Boolean {
|
||||
val name = path.substringAfterLast('/').lowercase()
|
||||
return name in BUILD_PREREQUISITE_FILENAMES
|
||||
}
|
||||
|
||||
// At most one dedicated bootstrap turn per stage (design #170 acceptance: "at most one").
|
||||
private const val BOOTSTRAP_BUDGET = 1
|
||||
private val PREREQUISITE_PATH_RE = Regex("""missing build prerequisite '([^']+)'""")
|
||||
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",
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
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.FileWrittenEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkspaceVerificationObservedEvent
|
||||
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.transitions.graph.BuildExpectation
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Replay-safe workspace state key (design 2026-07-15 seam 2). The workspace's verified identity is
|
||||
* the folded FileWritten manifest — each path mapped to its latest recorded post-image hash. Hashing
|
||||
* the sorted map yields a key that changes iff a file changed, so a verification observation stamped
|
||||
* with this key is invalidated automatically by any later write. Derived purely from recorded events
|
||||
* (never rescans the filesystem), so replay reproduces the same key (invariants #8/#9).
|
||||
*/
|
||||
internal fun workspaceStateKey(events: List<StoredEvent>): String {
|
||||
val latest = linkedMapOf<String, String>()
|
||||
events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.forEach { fw -> latest[fw.path] = fw.postImageHash ?: "" }
|
||||
val canonical = latest.entries.sortedBy { it.key }.joinToString("\n") { "${it.key}=${it.value}" }
|
||||
return Integer.toHexString(canonical.hashCode())
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the known-good verification observation for a passing build gate, binding the result to the
|
||||
* current [workspaceStateKey]. Called from the execution gate on a clean run so the next stage can
|
||||
* tell a still-valid baseline from a stale one. Emits nothing for [BuildExpectation.NONE] (no build
|
||||
* claim manufactured — spec: a docs-only/no-write stage does not fabricate a compiler assertion).
|
||||
*/
|
||||
internal suspend fun SessionOrchestrator.recordWorkspaceVerification(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
expectation: BuildExpectation,
|
||||
command: String,
|
||||
passed: Boolean,
|
||||
) {
|
||||
val events = eventStore.read(sessionId)
|
||||
emit(
|
||||
sessionId,
|
||||
WorkspaceVerificationObservedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
stateKey = workspaceStateKey(events),
|
||||
expectation = expectation.name,
|
||||
command = command,
|
||||
passed = passed,
|
||||
changedPaths = stageWrittenPaths(sessionId, stageId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `verified baseline` L0 entry for the next implementation stage (design 2026-07-15 seam 2). If
|
||||
* the latest passing [WorkspaceVerificationObservedEvent]'s state key still equals the CURRENT state
|
||||
* key, the workspace is known-good and the entry states the verified command + changed paths. If the
|
||||
* workspace changed since (key mismatch), the entry says the baseline is stale and the stage must
|
||||
* re-verify — it never claims a skipped compiler assertion passed. Empty when nothing has verified yet.
|
||||
*/
|
||||
internal suspend fun SessionOrchestrator.verifiedBaselineEntries(sessionId: SessionId): List<ContextEntry> {
|
||||
val events = eventStore.read(sessionId)
|
||||
val lastPass = events.mapNotNull { it.payload as? WorkspaceVerificationObservedEvent }
|
||||
.lastOrNull { it.passed } ?: return emptyList()
|
||||
val current = workspaceStateKey(events)
|
||||
val fresh = current == lastPass.stateKey
|
||||
val content = if (fresh) {
|
||||
"## Verified baseline (known-good)\nThe workspace passed `${lastPass.command}` " +
|
||||
"(${lastPass.expectation}) at the current state. Build/typecheck is green as of these paths: " +
|
||||
"${lastPass.changedPaths.joinToString(", ").ifBlank { "(no declared write paths)" }}. " +
|
||||
"Build on it; do not re-derive what already compiles."
|
||||
} else {
|
||||
"## Verified baseline (STALE)\nThe last green verification (`${lastPass.command}`) was for an " +
|
||||
"earlier workspace state; files changed since, so it is no longer authoritative. Re-run the " +
|
||||
"build gate before treating the workspace as known-good."
|
||||
}
|
||||
return listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = content,
|
||||
sourceType = "verifiedBaseline",
|
||||
sourceId = lastPass.stateKey,
|
||||
tokenEstimate = estimateTokens(content),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
}
|
||||
+65
-13
@@ -3,6 +3,7 @@ package com.correx.core.kernel.concept
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
@@ -11,6 +12,7 @@ import com.correx.core.events.events.WorkflowFailedEvent
|
||||
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.events.types.ToolInvocationId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -56,13 +58,29 @@ class ConceptCompilerProjectionTest {
|
||||
private fun failed(stage: String = "impl", session: String = "s1") =
|
||||
stored(StageFailedEvent(SessionId(session), StageId(stage), TransitionId("t"), "x"), session)
|
||||
|
||||
private fun wrote(path: String, hash: String? = "cas1", session: String = "s1") =
|
||||
stored(
|
||||
FileWrittenEvent(
|
||||
invocationId = ToolInvocationId("inv"),
|
||||
sessionId = SessionId(session),
|
||||
path = path,
|
||||
postImageHash = hash,
|
||||
preExisted = true,
|
||||
timestampMs = 1L,
|
||||
),
|
||||
session,
|
||||
)
|
||||
|
||||
private fun fold(events: List<StoredEvent>) =
|
||||
events.fold(projection.initial(), projection::apply)
|
||||
|
||||
// All helpers default to reason "boom\ndetail" on gate "lint" → the single class key "lint:boom".
|
||||
private val ConceptCompilerState.only get() = clusters.values.single()
|
||||
|
||||
@Test
|
||||
fun `retry then StageCompleted counts one validated fix`() {
|
||||
val state = fold(listOf(retry("fp1"), completed()))
|
||||
assertEquals(1, state.clusters["fp1"]?.validatedFixes)
|
||||
assertEquals(1, state.only.validatedFixes)
|
||||
assertTrue(state.promotable().isEmpty())
|
||||
}
|
||||
|
||||
@@ -75,12 +93,27 @@ class ConceptCompilerProjectionTest {
|
||||
retry("fp1", session = "sc"), completed(session = "sc"),
|
||||
),
|
||||
)
|
||||
assertEquals(3, state.clusters["fp1"]?.validatedFixes)
|
||||
assertEquals(listOf("fp1"), state.promotable().map { it.fingerprint })
|
||||
assertEquals(3, state.only.validatedFixes)
|
||||
assertEquals(listOf("lint:boom"), state.promotable().map { it.classKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageFailed while fingerprint open contradicts the cluster`() {
|
||||
fun `distinct fingerprints of the same failure class aggregate into one concept`() {
|
||||
// Same normalized signature, different volatile tokens (paths/line numbers) → one class key.
|
||||
val state = fold(
|
||||
listOf(
|
||||
retry("fpA", session = "sa", reason = "TS2322 in 'src/App.tsx:12'"), completed(session = "sa"),
|
||||
retry("fpB", session = "sb", reason = "TS2322 in 'src/Nav.tsx:88'"), completed(session = "sb"),
|
||||
retry("fpC", session = "sc", reason = "TS2322 in 'src/Home.tsx:5'"), completed(session = "sc"),
|
||||
),
|
||||
)
|
||||
assertEquals(1, state.clusters.size)
|
||||
assertEquals(3, state.only.validatedFixes)
|
||||
assertEquals(listOf("lint:ts# in <str>"), state.promotable().map { it.classKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageFailed while class open contradicts the cluster`() {
|
||||
val state = fold(
|
||||
listOf(
|
||||
retry("fp1", session = "sa"), completed(session = "sa"),
|
||||
@@ -88,37 +121,56 @@ class ConceptCompilerProjectionTest {
|
||||
retry("fp1", session = "sc"), failed(session = "sc"),
|
||||
),
|
||||
)
|
||||
assertTrue(state.clusters["fp1"]!!.contradicted)
|
||||
assertTrue(state.only.contradicted)
|
||||
assertTrue(state.promotable().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailed contradicts every open fingerprint in that session`() {
|
||||
fun `WorkflowFailed contradicts every open class in that session`() {
|
||||
val state = fold(
|
||||
listOf(
|
||||
retry("fp1", stage = "a", session = "sa"),
|
||||
retry("fp2", stage = "b", session = "sa"),
|
||||
retry("fp1", stage = "a", session = "sa", reason = "alpha broke"),
|
||||
retry("fp2", stage = "b", session = "sa", reason = "beta broke"),
|
||||
stored(WorkflowFailedEvent(SessionId("sa"), StageId("a"), "dead", true), "sa"),
|
||||
),
|
||||
)
|
||||
assertTrue(state.clusters["fp1"]!!.contradicted)
|
||||
assertTrue(state.clusters["fp2"]!!.contradicted)
|
||||
assertEquals(2, state.clusters.size)
|
||||
assertTrue(state.clusters.values.all { it.contradicted })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promotion is idempotent - a promoted fingerprint is not promotable again`() {
|
||||
fun `promotion is idempotent - a promoted class is not promotable again`() {
|
||||
val base = listOf(
|
||||
retry("fp1", session = "sa"), completed(session = "sa"),
|
||||
retry("fp1", session = "sb"), completed(session = "sb"),
|
||||
retry("fp1", session = "sc"), completed(session = "sc"),
|
||||
)
|
||||
val promoted = base + stored(ConceptPromotedEvent(SessionId("sc"), "fp1", "lint", "concept", 3))
|
||||
val promoted = base + stored(ConceptPromotedEvent(SessionId("sc"), "fp1", "lint:boom", "lint", "concept", 3))
|
||||
assertTrue(fold(promoted).promotable().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file written between retry and completion is captured as the validated fix ref`() {
|
||||
val state = fold(listOf(retry("fp1"), wrote("src/App.tsx", "hashA"), completed()))
|
||||
assertEquals("src/App.tsx", state.only.fixPath)
|
||||
assertEquals("hashA", state.only.fixHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first validated fix ref wins and a later stage's writes do not back-attribute`() {
|
||||
val state = fold(
|
||||
listOf(
|
||||
retry("fp1", session = "sa"), wrote("first.kt", "h1", session = "sa"), completed(session = "sa"),
|
||||
retry("fp1", session = "sb"), wrote("second.kt", "h2", session = "sb"), completed(session = "sb"),
|
||||
),
|
||||
)
|
||||
assertEquals("first.kt", state.only.fixPath)
|
||||
assertEquals("h1", state.only.fixHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signature is the first line of the failure reason`() {
|
||||
val state = fold(listOf(retry("fp1", reason = "detekt: MagicNumber\ntrace..."), completed()))
|
||||
assertEquals("detekt: MagicNumber", state.clusters["fp1"]?.signature)
|
||||
assertEquals("detekt: MagicNumber", state.only.signature)
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
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.transitions.graph.StageConfig
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BuildPrerequisiteDecisionTest {
|
||||
|
||||
private val reason =
|
||||
"stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " +
|
||||
"(3 blocked attempts). Create or repair the project setup before continuing."
|
||||
|
||||
@Test
|
||||
fun `decision maps the three facts to one bounded path`() {
|
||||
// not writable → route to a stage that can create the file.
|
||||
assertEquals(
|
||||
PrerequisiteAction.ROUTE_TO_RECOVERY,
|
||||
decidePrerequisiteAction(writable = false, inScope = true, bootstrapBudgetLeft = true),
|
||||
)
|
||||
// writable but out of scope → ask, don't guess.
|
||||
assertEquals(
|
||||
PrerequisiteAction.CLARIFY,
|
||||
decidePrerequisiteAction(writable = true, inScope = false, bootstrapBudgetLeft = true),
|
||||
)
|
||||
// writable, in scope, budget → grant the one bootstrap turn.
|
||||
assertEquals(
|
||||
PrerequisiteAction.BOOTSTRAP,
|
||||
decidePrerequisiteAction(writable = true, inScope = true, bootstrapBudgetLeft = true),
|
||||
)
|
||||
// writable, in scope, budget spent → escalate (the one turn didn't clear it).
|
||||
assertEquals(
|
||||
PrerequisiteAction.ESCALATE,
|
||||
decidePrerequisiteAction(writable = true, inScope = true, bootstrapBudgetLeft = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `path is extracted from the block reason`() {
|
||||
assertEquals("frontend/package.json", buildPrerequisitePath(reason))
|
||||
assertNull(buildPrerequisitePath("some unrelated failure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unconstrained stage owns everything but a scoped stage only its territory`() {
|
||||
val unconstrained = StageConfig()
|
||||
assertTrue(pathInDeclaredScope(unconstrained, "frontend/package.json"))
|
||||
|
||||
val scoped = StageConfig(touches = listOf("frontend/**"))
|
||||
assertTrue(pathInDeclaredScope(scoped, "frontend/package.json"))
|
||||
assertFalse(pathInDeclaredScope(scoped, "backend/package.json"))
|
||||
|
||||
val byExpected = StageConfig(touches = listOf("src/**"), expectedFiles = listOf("package.json"))
|
||||
assertTrue(pathInDeclaredScope(byExpected, "package.json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bootstrap budget counts prior grants for this stage only`() {
|
||||
val stage = StageId("impl")
|
||||
assertEquals(0, bootstrapAttemptCount(emptyList(), stage))
|
||||
val events = listOf(
|
||||
bootstrap(stage, "package.json"),
|
||||
bootstrap(StageId("other"), "package.json"),
|
||||
)
|
||||
assertEquals(1, bootstrapAttemptCount(events, stage))
|
||||
}
|
||||
|
||||
private var seq = 0L
|
||||
private fun bootstrap(stageId: StageId, path: String) = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e${seq++}"),
|
||||
sessionId = SessionId("s1"),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = BuildPrerequisiteBootstrapAttemptedEvent(SessionId("s1"), stageId, path, "why") as EventPayload,
|
||||
)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
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 kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PlanPatternMiningTest {
|
||||
|
||||
@Test
|
||||
fun `only a session whose executed plan completed is mined`() {
|
||||
val events =
|
||||
intent("s1", "build a react frontend that compiles") +
|
||||
locked("s1", "wf-s1", listOf("scaffold", "impl")) +
|
||||
completed("s1", "wf-s1") + // executed plan completed → mined
|
||||
intent("s2", "build a react frontend that compiles") +
|
||||
locked("s2", "wf-s2", listOf("a", "b")) // no completion → not mined
|
||||
val shapes = mineSuccessfulPlanShapes(events, exclude = "current")
|
||||
assertEquals(listOf("scaffold", "impl"), shapes.single().stageSequence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the current session is never mined from itself`() {
|
||||
val events = intent("me", "x y z frontend") + locked("me", "wf", listOf("a")) + completed("me", "wf")
|
||||
assertTrue(mineSuccessfulPlanShapes(events, exclude = "me").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyword overlap ignores stopwords and short tokens`() {
|
||||
val a = intentKeywords("Build a React frontend that compiles")
|
||||
val b = intentKeywords("Create a React frontend with routing")
|
||||
assertTrue("react" in a && "frontend" in a)
|
||||
assertTrue("the" !in a && "a" !in a)
|
||||
assertTrue(keywordOverlap(a, b) > 0.0)
|
||||
assertEquals(0.0, keywordOverlap(a, emptySet()))
|
||||
}
|
||||
|
||||
private var seq = 0L
|
||||
private fun ev(sid: String, payload: EventPayload) = listOf(
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e${seq++}"),
|
||||
sessionId = SessionId(sid),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = payload,
|
||||
),
|
||||
)
|
||||
|
||||
private fun intent(sid: String, text: String) = ev(sid, InitialIntentEvent(SessionId(sid), text))
|
||||
private fun locked(sid: String, wf: String, stages: List<String>) = ev(
|
||||
sid,
|
||||
ExecutionPlanLockedEvent(SessionId(sid), ArtifactId("execution_plan"), wf, stages, stages.first()),
|
||||
)
|
||||
private fun completed(sid: String, wf: String) =
|
||||
ev(sid, WorkflowCompletedEvent(SessionId(sid), StageId("end"), 2, wf))
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SelectPromotedConceptsTest {
|
||||
|
||||
private fun concept(classKey: String, gate: String, occ: Int) = ConceptPromotedEvent(
|
||||
sessionId = SessionId("__concept_compiler__"),
|
||||
fingerprint = classKey,
|
||||
classKey = classKey,
|
||||
gate = gate,
|
||||
conceptText = "text for $classKey",
|
||||
occurrences = occ,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a file-write-requiring concept is withheld from a stage lacking file_write`() {
|
||||
val promoted = listOf(concept("contract:x", "contract", 5))
|
||||
assertTrue(selectPromotedConcepts(promoted, allowedTools = setOf("shell")).isEmpty())
|
||||
assertEquals(1, selectPromotedConcepts(promoted, allowedTools = setOf("file_write")).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gates with no required capability are broadly relevant`() {
|
||||
val promoted = listOf(concept("lint:y", "lint", 3))
|
||||
assertEquals(1, selectPromotedConcepts(promoted, allowedTools = setOf("shell")).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a concept contradicted after promotion is withheld`() {
|
||||
val promoted = listOf(concept("lint:y", "lint", 4))
|
||||
assertTrue(
|
||||
selectPromotedConcepts(promoted, allowedTools = setOf("shell"), contradicted = setOf("lint:y")).isEmpty(),
|
||||
)
|
||||
assertEquals(1, selectPromotedConcepts(promoted, allowedTools = setOf("shell")).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `top concepts by occurrence are kept within the cap`() {
|
||||
val promoted = (1..5).map { concept("lint:$it", "lint", occ = it) }
|
||||
val picked = selectPromotedConcepts(promoted, allowedTools = emptySet(), limit = 3)
|
||||
assertEquals(listOf("lint:5", "lint:4", "lint:3"), picked.map { it.classKey })
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WorkspaceStateKeyTest {
|
||||
|
||||
@Test
|
||||
fun `key is stable for the same manifest and independent of write order`() {
|
||||
val a = workspaceStateKey(listOf(wrote("a.kt", "h1"), wrote("b.kt", "h2")))
|
||||
val b = workspaceStateKey(listOf(wrote("b.kt", "h2"), wrote("a.kt", "h1")))
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later write to a file changes the key`() {
|
||||
val before = workspaceStateKey(listOf(wrote("a.kt", "h1")))
|
||||
val after = workspaceStateKey(listOf(wrote("a.kt", "h1"), wrote("a.kt", "h2")))
|
||||
assertNotEquals(before, after, "a changed post-image must invalidate a prior baseline")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest post-image hash wins for a repeatedly-written path`() {
|
||||
val single = workspaceStateKey(listOf(wrote("a.kt", "h2")))
|
||||
val rewritten = workspaceStateKey(listOf(wrote("a.kt", "h1"), wrote("a.kt", "h2")))
|
||||
assertEquals(single, rewritten)
|
||||
}
|
||||
|
||||
private var seq = 0L
|
||||
private fun wrote(path: String, hash: String) = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e${seq++}"),
|
||||
sessionId = SessionId("s1"),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = FileWrittenEvent(
|
||||
invocationId = ToolInvocationId("inv"),
|
||||
sessionId = SessionId("s1"),
|
||||
path = path,
|
||||
postImageHash = hash,
|
||||
preExisted = false,
|
||||
timestampMs = 1L,
|
||||
) as EventPayload,
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,107 @@
|
||||
# Closed-loop workspace
|
||||
|
||||
## Invariant
|
||||
## Decision
|
||||
|
||||
After every code-producing stage, the workspace has a recorded verification observation. A passing observation is the only state the next implementation stage may treat as known-good; replay reads the event rather than re-running a command.
|
||||
Treat workspace truth as a recorded control-plane fact, not a terminal-stage hope. A plan may run
|
||||
only after its declared assumptions are grounded; every code-producing stage must leave a recorded
|
||||
verified increment before another implementation stage proceeds. Artifact-schema validation remains
|
||||
useful, but it is not evidence that the workspace compiles, builds, or satisfies its declared
|
||||
preconditions.
|
||||
|
||||
## Seams
|
||||
## Known-good workspace invariant
|
||||
|
||||
1. Before locking a plan, ground declared writes, referenced paths, and build prerequisites against the workspace/index. Unknown or absent prerequisites return the plan to the architect.
|
||||
2. For each write-declaring stage, run the appropriate static/build command and record its result. Failures retry or route through recovery before downstream work continues.
|
||||
3. Repeated hard precondition observations (for example `REFERENCE_EXISTS`) become a stage failure or bootstrap/clarification decision rather than independent rejected tool calls.
|
||||
After a stage that writes code/configuration, the orchestrator records one verification observation
|
||||
for the workspace state produced by that stage. The next implementation stage may rely only on a
|
||||
passing observation whose workspace state key is the current recorded state key.
|
||||
|
||||
## Delivery order
|
||||
- A passing observation is the authoritative `known-good` baseline.
|
||||
- A failed or unavailable observation is a local stage failure: retry the owner, route a recovery
|
||||
ticket, or ask for clarification; do not continue the normal plan edge.
|
||||
- A docs-only stage and a stage with no declared writes do not manufacture a compiler/build claim.
|
||||
- Replay reads the recorded observation and result. It never reruns a command or rescans the live
|
||||
workspace.
|
||||
|
||||
- Per-write-stage build gates and truthful deferred contract verdicts (#162).
|
||||
- Consume repeated build-critical `REFERENCE_EXISTS` blocks (#163): three blocks for one manifest/config path now create a recovery-eligible precondition failure.
|
||||
- Ground plan prerequisites before plan lock; then use the verified observation as the next-stage planning baseline.
|
||||
- Preserve recovery escalation for failures that still cannot reach a verified increment (#165).
|
||||
The existing `StaticAnalysisCompletedEvent` records command output. The implementation should add a
|
||||
small workspace-verification result event (or extend that event deliberately) that binds: session,
|
||||
stage, state key, declared write paths, selected command/expectation, result, and the observation
|
||||
sequence. That binding prevents a later dirty workspace from being mistaken for the state that was
|
||||
verified.
|
||||
|
||||
## Seam 1: plan grounding before lock
|
||||
|
||||
Plan compilation remains deterministic. Grounding is a distinct phase after structural compilation
|
||||
and before `ExecutionPlanLockedEvent`, because it needs recorded workspace facts.
|
||||
|
||||
Inputs, all from the current session's recorded observations:
|
||||
|
||||
| Check | Evidence | Outcome |
|
||||
|---|---|---|
|
||||
| Declared writes/touches | workspace root and path policy; plan manifest | reject unsafe/out-of-scope paths before lock |
|
||||
| Referenced existing paths | repo-map/workspace-state index | report an absent or ambiguous reference to the architect |
|
||||
| Referenced symbols | grounding-grade symbol index, not L3 similarity | report unresolved symbols with candidate paths |
|
||||
| Build prerequisites | project profile commands plus detected manifest/config files | require an explicit bootstrap stage, clarification, or verified prerequisite |
|
||||
| Stage ordering | writes, prerequisites, and verification requirements | reject a stage that depends on an unverified prior increment |
|
||||
|
||||
The repo map is an input catalog, not proof: its L3 embeddings are deliberately excluded from this
|
||||
phase. The grounding index must expose exact paths and best-effort symbols from an observation
|
||||
recorded at plan time. If the index cannot prove an assumption, the outcome is `unknown`, never
|
||||
`present`.
|
||||
|
||||
The phase emits a `PlanGroundingEvaluated` observation containing the plan identity, workspace state
|
||||
key, findings, and verdict (`pass`, `return_to_architect`, or `clarification_required`). A non-pass
|
||||
verdict prevents the plan lock; the architect receives compact findings and produces a new plan.
|
||||
That makes a doomed scaffold fail at stage 0 rather than after downstream files accumulate.
|
||||
|
||||
## Seam 2: verified increments in the orchestrator
|
||||
|
||||
For a stage with declared writes, the stage-success path is:
|
||||
|
||||
```text
|
||||
write/tool evidence → contract/static checks → selected build/test command
|
||||
→ WorkspaceVerificationObserved(pass, stateKey) → transition to next implementation stage
|
||||
```
|
||||
|
||||
On a failed command, missing prerequisite, or state-key mismatch:
|
||||
|
||||
```text
|
||||
WorkspaceVerificationObserved(fail/unavailable, evidence)
|
||||
→ retry feedback to owner → no-progress recovery arbiter → terminal failure only after escalation
|
||||
```
|
||||
|
||||
The selected command is deterministic from the stage's `buildExpectation`, the bound project
|
||||
profile, and the recorded prerequisite classification. The model cannot choose a shell command as
|
||||
the truth test. `setup` may run only when configured by the operator and is recorded separately;
|
||||
its success is not itself a green verification result.
|
||||
|
||||
The next-stage context receives a compact `verified baseline` entry: state key, verified command,
|
||||
and changed paths. It must not receive a claim that a skipped/deferred compiler assertion passed.
|
||||
If the stage's workspace changed after verification, the baseline is invalid and the next stage must
|
||||
re-observe/verify before treating it as known-good.
|
||||
|
||||
## Early precondition signals
|
||||
|
||||
`REFERENCE_EXISTS` and similar intent-plane blocks are observations, not disposable ReAct errors.
|
||||
Repeated blocks for one build-critical path become a `workspace_precondition` failure with the path
|
||||
and attempts recorded. The recovery owner either creates the necessary bootstrap/configuration, or
|
||||
routes to clarification when the authoritative intent cannot decide whether that path should exist.
|
||||
This is an early warning; the per-stage verification remains the final truth check.
|
||||
|
||||
## Increment sequence
|
||||
|
||||
1. **#162 — per-write verification:** compiler assertions render as deferred/skipped and
|
||||
write-declaring stages run their configured deterministic gate.
|
||||
2. **#163 — consume repeated hard blockers:** promote repeated build-critical missing paths into
|
||||
recovery-eligible precondition failures.
|
||||
3. **Grounding index and phase:** record an exact path/symbol/prerequisite snapshot, evaluate the
|
||||
compiled plan against it, and prevent a non-grounded plan from locking.
|
||||
4. **Verification invariant:** bind command results to workspace state keys; inject the recorded
|
||||
verified baseline into subsequent implementation stages.
|
||||
5. **#165 — no-progress recovery:** preserve intent-holder escalation for an increment that still
|
||||
cannot become verified.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not use semantic L3 retrieval as a plan-grounding oracle.
|
||||
- Do not silently synthesize ecosystem-specific manifests from a failed command.
|
||||
- Do not rerun commands, inspect the filesystem, or reconstruct verification during replay.
|
||||
- Do not make a terminal verifier the first build truth check for a multi-stage implementation run.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class InfrastructureModuleModelTest {
|
||||
val descriptor = InfrastructureModule.modelConfigToDescriptor(config)
|
||||
|
||||
assertEquals("bare-model", descriptor.modelId)
|
||||
assertEquals(8192, descriptor.contextSize)
|
||||
assertEquals(24_576, descriptor.contextSize)
|
||||
assertEquals(0, descriptor.capabilities.size)
|
||||
}
|
||||
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.events.events.PlanGroundingVerdict
|
||||
import com.correx.core.transitions.graph.BuildExpectation
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.PathMatcher
|
||||
|
||||
/** Grounding verdict plus the compact findings handed back to the architect on a non-pass. */
|
||||
data class PlanGroundingResult(
|
||||
val verdict: PlanGroundingVerdict,
|
||||
val findings: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Deterministic plan grounding (design 2026-07-15 seam 1). Pure over the compiled [WorkflowGraph]
|
||||
* plus recorded workspace facts (repo-map paths + project-profile command aliases) — zero inference,
|
||||
* zero I/O, so it is replay-safe by construction and complements the structural [PlanLinter].
|
||||
*
|
||||
* v1 grounds **build prerequisites** (spec row 4): a stage that will run a real build command
|
||||
* ([BuildExpectation] resolves to a profile alias that IS configured) against a build manifest that
|
||||
* neither exists in the workspace ([repoMapPaths]) nor is produced by any plan stage's declared
|
||||
* writes is doomed — the gate would fail forever with nothing to create the prerequisite. That is a
|
||||
* `return_to_architect` finding, caught before lock. A stage whose plan scaffolds the manifest
|
||||
* earlier, or whose profile has no command (the gate degrades to a safe skip), is grounded.
|
||||
*
|
||||
* Path/symbol reference grounding (spec rows 2/3) needs a grounding-grade symbol index and is
|
||||
* deferred; those assumptions resolve to `unknown`, never `present`, so this phase never fabricates a
|
||||
* "present" verdict it cannot prove.
|
||||
*/
|
||||
object PlanGrounder {
|
||||
|
||||
fun ground(
|
||||
graph: WorkflowGraph,
|
||||
repoMapPaths: Set<String>,
|
||||
profileCommands: Map<String, String>,
|
||||
): PlanGroundingResult {
|
||||
val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
|
||||
val manifestProducedByPlan = graph.stages.values
|
||||
.flatMap { it.writeManifest + it.expectedFiles }
|
||||
.any(::isBuildManifest)
|
||||
val prerequisiteAvailable = manifestInWorkspace || manifestProducedByPlan
|
||||
|
||||
// Files any stage in the plan will create — the "will exist by run time" set for scope grounding.
|
||||
val createdPaths = graph.stages.values.flatMap { it.expectedFiles + it.writeManifest }
|
||||
|
||||
val findings = graph.stages.entries.flatMap { (id, cfg) ->
|
||||
buildList {
|
||||
val alias = cfg.buildExpectation.takeIf { it != BuildExpectation.NONE }?.commandAlias
|
||||
val commandConfigured = alias != null && !profileCommands[alias].isNullOrBlank()
|
||||
if (commandConfigured && !prerequisiteAvailable) {
|
||||
add(
|
||||
"stage ${id.value}: declares a ${cfg.buildExpectation} build ('$alias') but the workspace " +
|
||||
"has no build manifest and no plan stage creates one — add a bootstrap stage that " +
|
||||
"produces the manifest/config, or verify the prerequisite before this stage runs.",
|
||||
)
|
||||
}
|
||||
// Scope grounding (design 2026-07-15 seam 1, spec row 2): a stage confined to a `touches`
|
||||
// scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
|
||||
// under is grounded on a directory that will never exist — the exact session-a35cf1e3
|
||||
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
|
||||
cfg.touches
|
||||
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
||||
.forEach { glob ->
|
||||
add(
|
||||
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
||||
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PlanGroundingResult(
|
||||
verdict = if (findings.isEmpty()) PlanGroundingVerdict.PASS else PlanGroundingVerdict.RETURN_TO_ARCHITECT,
|
||||
findings = findings,
|
||||
)
|
||||
}
|
||||
|
||||
/** True if any existing repo-map path or any plan-created path falls under the [glob] scope. */
|
||||
private fun scopeSatisfied(glob: String, repoMapPaths: Set<String>, createdPaths: List<String>): Boolean {
|
||||
val matcher: PathMatcher = runCatching {
|
||||
FileSystems.getDefault().getPathMatcher("glob:${glob.trimStart('/')}")
|
||||
}.getOrElse { return true } // ponytail: an unparseable glob is not a grounding failure — don't block on it.
|
||||
fun matches(p: String) = runCatching { matcher.matches(Path.of(p.trimStart('/'))) }.getOrDefault(false)
|
||||
return repoMapPaths.any(::matches) || createdPaths.any(::matches)
|
||||
}
|
||||
|
||||
private fun isBuildManifest(path: String): Boolean =
|
||||
path.substringAfterLast('/').lowercase() in BUILD_MANIFEST_FILENAMES
|
||||
|
||||
// Mirrors the runtime BUILD_PREREQUISITE_FILENAMES in SessionOrchestratorPreconditions — the same
|
||||
// files that trip a repeated-reference precondition failure at run time are the prerequisites
|
||||
// grounded here at plan time (design #167/#170 complement).
|
||||
private val BUILD_MANIFEST_FILENAMES = setOf(
|
||||
"package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
|
||||
)
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.events.events.PlanGroundingVerdict
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.conditions.AlwaysTrue
|
||||
import com.correx.core.transitions.graph.BuildExpectation
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanGrounderTest {
|
||||
|
||||
private fun graph(vararg stages: Pair<String, StageConfig>) = WorkflowGraph(
|
||||
id = "test",
|
||||
stages = stages.associate { StageId(it.first) to it.second },
|
||||
transitions = stages.toList().zipWithNext { (f, _), (t, _) ->
|
||||
TransitionEdge(TransitionId("$f-$t"), StageId(f), StageId(t), AlwaysTrue)
|
||||
}.toSet(),
|
||||
start = StageId(stages.first().first),
|
||||
)
|
||||
|
||||
private val cmds = mapOf("build" to "npm run build", "typecheck" to "tsc --noEmit")
|
||||
|
||||
@Test
|
||||
fun `stage building against a missing prerequisite is returned to architect`() {
|
||||
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.PROJECT))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = setOf("src/main.ts"), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.RETURN_TO_ARCHITECT, r.verdict)
|
||||
assertTrue(r.findings.single().contains("impl"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manifest present in repo map grounds the build`() {
|
||||
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.PROJECT))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = setOf("package.json"), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan stage that scaffolds the manifest grounds a later build`() {
|
||||
val g = graph(
|
||||
"scaffold" to StageConfig(writeManifest = listOf("package.json")),
|
||||
"impl" to StageConfig(buildExpectation = BuildExpectation.PROJECT),
|
||||
)
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no configured command degrades to a safe pass`() {
|
||||
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.PROJECT))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = emptyMap())
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage scoped to a path that neither exists nor is created is ungrounded`() {
|
||||
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = setOf("backend/main.kt"), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.RETURN_TO_ARCHITECT, r.verdict)
|
||||
assertTrue(r.findings.single().contains("frontend/**"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scope satisfied by an existing repo path grounds`() {
|
||||
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = setOf("frontend/App.tsx"), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scope satisfied by a file a plan stage creates grounds`() {
|
||||
val g = graph(
|
||||
"scaffold" to StageConfig(expectedFiles = listOf("frontend/index.html")),
|
||||
"impl" to StageConfig(touches = listOf("frontend/**")),
|
||||
)
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NONE expectation never grounds`() {
|
||||
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.NONE))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user