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:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user