Compare commits
12 Commits
master
...
9e62097c97
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e62097c97 | |||
| 1dca8c7f25 | |||
| e5a5cddc96 | |||
| 3e31ebcc1e | |||
| 0a89fafd45 | |||
| 63e8b4f5d6 | |||
| 633da3d2df | |||
| 59a0ad3c2b | |||
| a2b97221d5 | |||
| 53d8108189 | |||
| ed7efb6072 | |||
| 3a48ecd24f |
@@ -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 ->
|
||||
|
||||
@@ -19,6 +19,8 @@ All sources under `apps/server/src/`.
|
||||
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
|
||||
- `GET /stats` — metrics report (MetricsProjection)
|
||||
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
||||
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
||||
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
||||
|
||||
### WebSocket protocol (`/ws`)
|
||||
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
||||
|
||||
@@ -210,14 +210,21 @@ fun main() {
|
||||
val explicitWorkspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
|
||||
?.let { Path.of(it) }
|
||||
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
|
||||
// workingDir and workspaceRoot must resolve to the same tree by default — the tool-call
|
||||
// assessor's containment rules (PathContainmentRule, ManifestContainmentRule) only ever see
|
||||
// workspaceRoot, while FileWriteTool resolves relative paths against workingDir. If only one
|
||||
// is configured, each falls back to the other before falling back to process CWD, so the
|
||||
// assessor's containment check and the actual write always share one resolved root.
|
||||
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
|
||||
val workspaceRoot = explicitWorkspaceRoot ?: explicitWorkingDir ?: Path.of("").toAbsolutePath()
|
||||
val workingDir = explicitWorkingDir ?: workspaceRoot
|
||||
val bootWorkspace = resolveBootWorkspace(
|
||||
explicitWorkspaceRoot = explicitWorkspaceRoot,
|
||||
explicitWorkingDir = explicitWorkingDir,
|
||||
processWorkingDir = Path.of(""),
|
||||
)
|
||||
val workspaceRoot = bootWorkspace.workspaceRoot
|
||||
val workingDir = bootWorkspace.workingDir
|
||||
if (bootWorkspace.workingDirWasClamped) {
|
||||
log.warn(
|
||||
"configured working_dir {} is outside workspace_root {}; clamping working_dir to workspace_root",
|
||||
explicitWorkingDir,
|
||||
workspaceRoot,
|
||||
)
|
||||
}
|
||||
// One shared HTTP client backs both the default and per-workspace registries' research tools
|
||||
// (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
|
||||
// Lives for the process lifetime (shared across requests), so it's closed via shutdown hook
|
||||
@@ -262,7 +269,34 @@ fun main() {
|
||||
)
|
||||
// Retrieves full tool output the kernel spilled to CAS on truncation (ref shown in-context).
|
||||
val toolOutputTool = com.correx.infrastructure.tools.ToolOutputTool(artifactStore)
|
||||
val extraTools = taskTools + toolOutputTool
|
||||
// Mount configured MCP servers: each server's tools/list becomes Correx tools that ride the normal
|
||||
// ToolExecutor path (tier-gated, event-recorded, replay-safe — no special-casing). A server that
|
||||
// fails to start is logged and skipped rather than aborting the whole process.
|
||||
val mountedMcpServers = correxConfig.mcp.mapNotNull { mcpConfig ->
|
||||
runCatching {
|
||||
runBlocking {
|
||||
com.correx.infrastructure.tools.mcp.McpMounter.mount(
|
||||
serverId = mcpConfig.id,
|
||||
command = mcpConfig.command,
|
||||
env = mcpConfig.env,
|
||||
tier = runCatching { com.correx.core.approvals.Tier.valueOf(mcpConfig.tier) }
|
||||
.getOrDefault(com.correx.core.approvals.Tier.T2),
|
||||
)
|
||||
}
|
||||
}.onSuccess { log.info("Mounted MCP server '{}' ({} tools)", mcpConfig.id, it.tools.size) }
|
||||
.onFailure { log.warn("Failed to mount MCP server '{}': {}", mcpConfig.id, it.message) }
|
||||
.getOrNull()
|
||||
}
|
||||
if (mountedMcpServers.isNotEmpty()) {
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
mountedMcpServers.forEach { server ->
|
||||
runCatching { server.close() }
|
||||
.onFailure { log.warn("Error closing MCP server '{}': {}", server.serverId, it.message) }
|
||||
}
|
||||
})
|
||||
}
|
||||
val mcpTools = mountedMcpServers.flatMap { it.tools }
|
||||
val extraTools = taskTools + toolOutputTool + mcpTools
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfig(
|
||||
workspaceRoot,
|
||||
@@ -535,12 +569,17 @@ fun main() {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Heuristic concept compiler (design 2026-07-12-acr-concept-compiler.md): promotes recurring
|
||||
// validated failure→fix patterns into L3 as retrieval-on-demand concepts. Fires only on ≥N
|
||||
// cross-session validated fixes, so it's harmless to run unconditionally.
|
||||
val conceptCompilerService =
|
||||
com.correx.apps.server.concept.ConceptCompilerService(eventStore, embedder, l3MemoryStore)
|
||||
// Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
|
||||
// project.enabled / personalization.* applies live to the next session.
|
||||
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
|
||||
if (cfg.project.enabled) {
|
||||
com.correx.apps.server.memory.ProjectMemoryService(
|
||||
config = cfg.project,
|
||||
config = cfg.project.boundToWorkspace(workspaceRoot),
|
||||
embedder = embedder,
|
||||
l3MemoryStore = l3MemoryStore,
|
||||
journalRepository = decisionJournalRepository,
|
||||
@@ -638,6 +677,7 @@ fun main() {
|
||||
narrationMaxPerRun = correxConfig.talkie.narration.maxPerRun,
|
||||
projectMemory = projectMemory,
|
||||
architectContradictionChecker = architectContradictionChecker,
|
||||
conceptCompilerService = conceptCompilerService,
|
||||
configHolder = configHolder,
|
||||
freestyleDriver = freestyleDriver,
|
||||
operatorProfile = operatorProfile,
|
||||
@@ -648,6 +688,11 @@ fun main() {
|
||||
taskArtifactResolver = taskArtifactResolver,
|
||||
taskSessionResolver = taskSessionResolver,
|
||||
gitCommitReader = gitCommitReader,
|
||||
gitRunBranchTransport = if (correxConfig.git.enabled) {
|
||||
com.correx.apps.server.git.GitRunBranchTransport(eventStore, correxConfig.git)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
||||
// services. Built after the module so the rebuild hook can swap them in place.
|
||||
@@ -860,7 +905,7 @@ private fun buildToolConfig(
|
||||
toolsConfig: com.correx.core.config.ToolsConfig,
|
||||
research: com.correx.infrastructure.tools.ResearchToolConfig,
|
||||
): ToolConfig {
|
||||
val allowed = setOf(workspaceRoot, workingDir)
|
||||
val allowed = setOf(workspaceRoot)
|
||||
return ToolConfig(
|
||||
shell = ShellConfig(
|
||||
enabled = toolsConfig.shellEnabled,
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.correx.core.config.ProjectProfileLoader
|
||||
import com.correx.apps.server.memory.ArchitectContradictionChecker
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
@@ -103,6 +104,9 @@ class ServerModule(
|
||||
// (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook:
|
||||
// a server restart re-reads project.enabled, which is enough for this informational flag.
|
||||
private val architectContradictionChecker: ArchitectContradictionChecker? = null,
|
||||
// Heuristic concept compiler write-side (design 2026-07-12-acr-concept-compiler.md). Null disables
|
||||
// the promotion hook (tests). Live-only like the subscriptions below — never runs under replay.
|
||||
private val conceptCompilerService: com.correx.apps.server.concept.ConceptCompilerService? = null,
|
||||
// Live, swappable config. Null only in tests that don't exercise config editing; defaults to a
|
||||
// holder seeded from defaults so callers always have a value to read.
|
||||
val configHolder: com.correx.core.config.ConfigHolder =
|
||||
@@ -129,6 +133,9 @@ class ServerModule(
|
||||
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
|
||||
// (the endpoint then only acts on commits supplied in the request body).
|
||||
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
|
||||
// Optional plain-Git transport for a server-owned checkout. It creates and pushes a per-run
|
||||
// branch; null preserves the ordinary local-workspace lifecycle.
|
||||
private val gitRunBranchTransport: com.correx.apps.server.git.GitRunBranchTransport? = null,
|
||||
) {
|
||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||
orchestrator = orchestrator,
|
||||
@@ -240,6 +247,21 @@ class ServerModule(
|
||||
}
|
||||
.launchIn(moduleScope)
|
||||
}
|
||||
|
||||
// Heuristic concept compiler (design 2026-07-12-acr-concept-compiler.md). Live-only like the
|
||||
// hooks above: subscribeAll() replays nothing and ServerModule is never built under replay, so
|
||||
// promotion never re-fires on restart/replay (invariant #8). A StageCompleted may resolve a
|
||||
// validated failure→fix; re-fold the log and promote any newly-eligible fingerprint. Failures
|
||||
// are logged and swallowed — promotion is best-effort enrichment, never on the stage's path.
|
||||
conceptCompilerService?.let { compiler ->
|
||||
eventStore.subscribeAll()
|
||||
.filter { it.payload is StageCompletedEvent }
|
||||
.onEach {
|
||||
runCatching { compiler.runOnce() }
|
||||
.onFailure { e -> log.warn("concept compiler run failed: {}", e.message) }
|
||||
}
|
||||
.launchIn(moduleScope)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,17 +412,25 @@ class ServerModule(
|
||||
bindProjectProfile(sessionId)
|
||||
bindAgentInstructions(sessionId)
|
||||
runCatching {
|
||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||
freestyleHandoff(sessionId, graph, result)
|
||||
// Distil this run's decisions into durable project memory on completion.
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||
operatorProfile?.let { profile ->
|
||||
profileAdaptationService?.let { svc ->
|
||||
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
||||
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
||||
suspend fun runAndFinalize() {
|
||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||
freestyleHandoff(sessionId, graph, result)
|
||||
// Distil this run's decisions into durable project memory on completion.
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||
operatorProfile?.let { profile ->
|
||||
profileAdaptationService?.let { svc ->
|
||||
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
||||
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val workspaceRoot = sessionConfig.workspace?.workspaceRoot
|
||||
if (gitRunBranchTransport != null && workspaceRoot != null) {
|
||||
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
|
||||
} else {
|
||||
runAndFinalize()
|
||||
}
|
||||
}.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) }
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.correx.apps.server.concept
|
||||
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.inference.Embedder
|
||||
import com.correx.core.kernel.concept.ConceptCluster
|
||||
import com.correx.core.kernel.concept.ConceptCompilerProjection
|
||||
import com.correx.core.kernel.concept.DEFAULT_PROMOTION_THRESHOLD
|
||||
import com.correx.core.talkie.l3.L3MemoryEntry
|
||||
import com.correx.core.talkie.l3.L3MemoryStore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.UUID
|
||||
|
||||
private val log = LoggerFactory.getLogger(ConceptCompilerService::class.java)
|
||||
|
||||
/**
|
||||
* The write-side of the heuristic concept compiler (design 2026-07-12-acr-concept-compiler.md).
|
||||
* [ConceptCompilerProjection] does the deterministic clustering; this service is the single "write":
|
||||
* it folds the whole cross-session log, and for every fingerprint that has just become promotable it
|
||||
* appends a [ConceptPromotedEvent] (the authoritative record, idempotent under replay) and best-effort
|
||||
* injects the concept into L3 so it is retrieved on demand into future stage context — the same
|
||||
* pull/top-k path RepoKnowledge already uses, no broadcast. The L3 write is non-authoritative
|
||||
* (invariant #6); the event is truth, so an embed/store failure is logged and swallowed.
|
||||
*
|
||||
* Deterministic rule firing → no assessor gate needed (invariant #3/#7 hold trivially).
|
||||
*
|
||||
* ponytail: [runOnce] re-folds `allEvents()` on each call — O(n) in log size. Fine at local-first
|
||||
* scale and called on stage completions, not per-event. Switch to an incremental in-memory fold over
|
||||
* `subscribeAll()` if the log outgrows a full rescan.
|
||||
*/
|
||||
class ConceptCompilerService(
|
||||
private val eventStore: EventStore,
|
||||
private val embedder: Embedder,
|
||||
private val l3MemoryStore: L3MemoryStore,
|
||||
private val threshold: Int = DEFAULT_PROMOTION_THRESHOLD,
|
||||
) {
|
||||
private val projection = ConceptCompilerProjection()
|
||||
|
||||
/** Fold the full log, promote every newly-promotable fingerprint. Safe to call repeatedly. */
|
||||
suspend fun runOnce() {
|
||||
val state = eventStore.allEvents().fold(projection.initial(), projection::apply)
|
||||
state.promotable(threshold).forEach { promote(it) }
|
||||
}
|
||||
|
||||
private suspend fun promote(cluster: ConceptCluster) {
|
||||
val text = conceptText(cluster)
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = metadata(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
injectToL3(cluster.fingerprint, text)
|
||||
log.info("promoted concept {} ({}x, gate={})", cluster.fingerprint, cluster.validatedFixes, cluster.gate)
|
||||
}
|
||||
|
||||
private suspend fun injectToL3(fingerprint: String, text: String) {
|
||||
runCatching {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = "concept:$fingerprint",
|
||||
sessionId = SYSTEM_SESSION,
|
||||
turnId = "concept:$fingerprint",
|
||||
text = text,
|
||||
vector = embedder.embed(text),
|
||||
timestampMs = now,
|
||||
),
|
||||
)
|
||||
}.exceptionOrNull()?.let { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("L3 inject failed for concept {}: {}", fingerprint, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
sessionId = SYSTEM_SESSION,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Cross-session concepts live on their own stream, out of any user session's replay. */
|
||||
val SYSTEM_SESSION = SessionId("__concept_compiler__")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.correx.apps.server.git
|
||||
|
||||
import com.correx.core.config.GitConfig
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val GIT_TIMEOUT_SECONDS = 30L
|
||||
|
||||
/**
|
||||
* Plain-Git transport for a server-owned checkout. The mutex intentionally spans a whole run:
|
||||
* checkout is process-global state, so parallel sessions cannot safely share one working tree.
|
||||
*/
|
||||
class GitRunBranchTransport(
|
||||
private val eventStore: EventStore,
|
||||
private val config: GitConfig,
|
||||
) {
|
||||
private val checkoutLock = Mutex()
|
||||
|
||||
suspend fun <T> onRunBranch(
|
||||
sessionId: SessionId,
|
||||
workspaceRoot: Path,
|
||||
block: suspend () -> T,
|
||||
): T = checkoutLock.withLock {
|
||||
val prepared = prepare(sessionId, workspaceRoot)
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
val terminalStage = eventStore.read(sessionId).lastOrNull { event ->
|
||||
event.payload is WorkflowCompletedEvent || event.payload is WorkflowFailedEvent
|
||||
}?.payload?.let { payload ->
|
||||
when (payload) {
|
||||
is WorkflowCompletedEvent -> payload.terminalStageId.value
|
||||
is WorkflowFailedEvent -> payload.stageId.value
|
||||
else -> null
|
||||
}
|
||||
} ?: "unknown"
|
||||
pushTerminalBranch(sessionId, workspaceRoot, prepared, terminalStage)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepare(sessionId: SessionId, workspaceRoot: Path): PreparedBranch = withContext(Dispatchers.IO) {
|
||||
val branch = "run/${sessionId.value}"
|
||||
runGit(workspaceRoot, "fetch", config.remote, config.baseBranch)
|
||||
val baseRef = "${config.remote}/${config.baseBranch}"
|
||||
val baseSha = runGit(workspaceRoot, "rev-parse", baseRef).trim()
|
||||
runGit(workspaceRoot, "checkout", "-B", branch, baseRef)
|
||||
PreparedBranch(branch, baseSha)
|
||||
}
|
||||
|
||||
private suspend fun pushTerminalBranch(
|
||||
sessionId: SessionId,
|
||||
workspaceRoot: Path,
|
||||
prepared: PreparedBranch,
|
||||
terminalStage: String,
|
||||
) =
|
||||
withContext(Dispatchers.IO) {
|
||||
runGit(workspaceRoot, "add", "-A")
|
||||
if (runGitExitCode(workspaceRoot, "diff", "--cached", "--quiet") != 0) {
|
||||
val args = mutableListOf("commit", "-m", "correx run ${sessionId.value} terminal $terminalStage")
|
||||
if (config.author.isNotBlank()) args += "--author=${config.author}"
|
||||
runGit(workspaceRoot, *args.toTypedArray())
|
||||
}
|
||||
val headSha = runGit(workspaceRoot, "rev-parse", "HEAD").trim()
|
||||
runGit(workspaceRoot, "push", config.remote, "${prepared.branch}:${prepared.branch}")
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = RunBranchPushedEvent(sessionId, prepared.branch, prepared.baseSha, headSha),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun runGit(root: Path, vararg args: String): String {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
error("git ${args.joinToString(" ")} timed out")
|
||||
}
|
||||
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: ${output.trim()}" }
|
||||
return output
|
||||
}
|
||||
|
||||
private fun runGitExitCode(root: Path, vararg args: String): Int {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
error("git ${args.joinToString(" ")} timed out")
|
||||
}
|
||||
return process.exitValue()
|
||||
}
|
||||
|
||||
private data class PreparedBranch(val branch: String, val baseSha: String)
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ import com.correx.core.talkie.l3.L3Query
|
||||
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
|
||||
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
|
||||
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
|
||||
* [L3RepoKnowledgeRetriever]'s `"repomap:<repoRoot>:"` filter. Hits are also constrained to PRIOR
|
||||
* [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2:<repoRoot>:"` filter. Hits are also constrained to PRIOR
|
||||
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
|
||||
*/
|
||||
class ArchitectContradictionChecker(
|
||||
|
||||
+3
-2
@@ -35,8 +35,9 @@ class L3RepoKnowledgeRetriever(
|
||||
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
|
||||
val vector = embedder.embed(query)
|
||||
val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
|
||||
// Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision).
|
||||
.filter { it.entry.turnId.startsWith("repomap:$repoRoot:") }
|
||||
// Versioned trailing delimiter keeps sibling repo roots isolated and stale semantic
|
||||
// documents out after a repo-map descriptor format change.
|
||||
.filter { it.entry.turnId.startsWith(repoMapEmbeddingPrefix(repoRoot)) }
|
||||
val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE }
|
||||
if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() })
|
||||
return kept.take(k).map { it.toHit() }
|
||||
|
||||
@@ -110,13 +110,13 @@ class ProjectMemoryService(
|
||||
stateKey: String?,
|
||||
entries: List<RepoMapEntry>,
|
||||
) {
|
||||
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) {
|
||||
val tag = repoMapEmbeddingTag(repoRoot, stateKey)
|
||||
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix(tag)) {
|
||||
log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey)
|
||||
return
|
||||
}
|
||||
// Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
|
||||
// (/repo vs /repo2) under the retriever's startsWith filter.
|
||||
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:"
|
||||
// Docs are already surfaced via the "Docs available" catalog built straight from
|
||||
// RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
|
||||
// them into the same L3 namespace as code let generic textual similarity (e.g. "analyst",
|
||||
@@ -126,7 +126,11 @@ class ProjectMemoryService(
|
||||
// rather than the repo-map floor that was fixed then).
|
||||
entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
|
||||
runCatching {
|
||||
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
|
||||
val text = buildString {
|
||||
append(entry.path)
|
||||
if (entry.descriptor.isNotBlank()) append(": ").append(entry.descriptor)
|
||||
if (entry.symbols.isNotEmpty()) append("; symbols: ").append(entry.symbols.joinToString(", "))
|
||||
}
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
/**
|
||||
* Version the serialized repo-map embedding namespace. A descriptor change alters the semantic
|
||||
* document, so a new version deliberately ignores stale vectors and causes one re-embed per
|
||||
* recorded workspace state. The recorded repo-map event remains backwards compatible.
|
||||
*/
|
||||
internal const val REPO_MAP_EMBEDDING_VERSION = "v2"
|
||||
|
||||
internal fun repoMapEmbeddingPrefix(repoRoot: String): String =
|
||||
"repomap:$REPO_MAP_EMBEDDING_VERSION:$repoRoot:"
|
||||
|
||||
internal fun repoMapEmbeddingTag(repoRoot: String, stateKey: String?): String =
|
||||
stateKey?.let { repoMapEmbeddingPrefix(repoRoot) + it } ?: repoMapEmbeddingPrefix(repoRoot)
|
||||
@@ -20,7 +20,8 @@ interface RepoMapIndexerPort {
|
||||
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
|
||||
* nondeterministic environment observation) — the caller records the result as a
|
||||
* [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and
|
||||
* never re-scans (invariant #9). Paths + top-level symbol names only, never file bodies.
|
||||
* never re-scans (invariant #9). Entries retain paths, top-level symbols, and a bounded,
|
||||
* deterministic purpose descriptor; full file bodies are never stored in the repo map.
|
||||
*
|
||||
* Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest
|
||||
* ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank
|
||||
@@ -53,6 +54,7 @@ class RepoMapIndexer(
|
||||
path = path.relativeTo(repoRoot).toString(),
|
||||
score = (mtimes.getValue(path) - min).toDouble() / span,
|
||||
symbols = extractSymbols(path),
|
||||
descriptor = sourceDescriptor(path),
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.score }
|
||||
@@ -104,6 +106,35 @@ class RepoMapIndexer(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* A small semantic bridge between an intent ("context packing") and code whose class name
|
||||
* alone lacks those words. Package/module identity, leading KDoc/comments, and imports are
|
||||
* stable source facts; they are capped before the event and L3 embedding are recorded.
|
||||
*/
|
||||
private fun sourceDescriptor(path: Path): String {
|
||||
if (path.extension.equals("md", ignoreCase = true)) return docDescriptor(path).orEmpty()
|
||||
val lines = runCatching { path.readText().lineSequence().take(DESCRIPTOR_SCAN_LINES).toList() }
|
||||
.getOrDefault(emptyList())
|
||||
if (lines.isEmpty()) return ""
|
||||
val packageOrModule = lines.firstNotNullOfOrNull { line ->
|
||||
PACKAGE_OR_MODULE.find(line.trim())?.groupValues?.get(1)
|
||||
}
|
||||
val imports = lines.asSequence()
|
||||
.mapNotNull { IMPORT.find(it.trim())?.groupValues?.get(1) }
|
||||
.take(MAX_DESCRIPTOR_IMPORTS)
|
||||
.toList()
|
||||
val comment = lines.asSequence()
|
||||
.map { raw -> raw.trim() }
|
||||
.filter(::isCommentLine)
|
||||
.map { it.removePrefix("/**").removePrefix("*").removePrefix("//").removePrefix("#").trim() }
|
||||
.firstOrNull { it.length >= MIN_COMMENT_CHARS }
|
||||
return buildList {
|
||||
packageOrModule?.let { add("module $it") }
|
||||
if (imports.isNotEmpty()) add("uses ${imports.joinToString(", ")}")
|
||||
comment?.let { add(it) }
|
||||
}.joinToString("; ").truncateDescriptor()
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line "what is this doc about" descriptor, preferred in order: YAML frontmatter
|
||||
* `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line.
|
||||
@@ -133,13 +164,21 @@ class RepoMapIndexer(
|
||||
private fun String.truncateDescriptor(): String =
|
||||
if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + "…"
|
||||
|
||||
private fun isCommentLine(line: String): Boolean =
|
||||
line.startsWith("/**") || line.startsWith("*") || line.startsWith("//") || line.startsWith("#")
|
||||
|
||||
companion object {
|
||||
/** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
|
||||
val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys
|
||||
|
||||
private const val MAX_SYMBOLS_PER_FILE = 40
|
||||
private const val MAX_DESCRIPTOR_CHARS = 120
|
||||
private const val DESCRIPTOR_SCAN_LINES = 80
|
||||
private const val MAX_DESCRIPTOR_IMPORTS = 6
|
||||
private const val MIN_COMMENT_CHARS = 16
|
||||
private val FRONTMATTER_KEYS = listOf("description", "summary", "title")
|
||||
private val PACKAGE_OR_MODULE = Regex("""(?:package|module|namespace)\s+([A-Za-z0-9_.$/-]+)""")
|
||||
private val IMPORT = Regex("""(?:import|using)\s+([A-Za-z0-9_.$/*-]+)""")
|
||||
|
||||
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
|
||||
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.correx.apps.server.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.NewEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
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.TransitionId
|
||||
import com.correx.core.inference.Embedder
|
||||
import com.correx.core.talkie.l3.InMemoryL3MemoryStore
|
||||
import com.correx.core.talkie.l3.L3Query
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
|
||||
private class ConstantEmbedder(override val dimension: Int = 8) : Embedder {
|
||||
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
|
||||
}
|
||||
|
||||
class ConceptCompilerServiceTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val l3 = InMemoryL3MemoryStore()
|
||||
private val service = ConceptCompilerService(store, ConstantEmbedder(), l3, threshold = 3)
|
||||
|
||||
private suspend fun append(payload: EventPayload, session: String) {
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = SessionId(session),
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun validatedFix(fp: String, session: String) {
|
||||
append(
|
||||
RetryAttemptedEvent(
|
||||
sessionId = SessionId(session),
|
||||
stageId = StageId("impl"),
|
||||
attemptNumber = 1,
|
||||
maxAttempts = 3,
|
||||
failureReason = "detekt: MagicNumber\ntrace",
|
||||
gate = "lint",
|
||||
fingerprint = fp,
|
||||
),
|
||||
session,
|
||||
)
|
||||
append(StageCompletedEvent(SessionId(session), StageId("impl"), TransitionId("t")), session)
|
||||
}
|
||||
|
||||
private suspend fun promotedEvents(): List<ConceptPromotedEvent> =
|
||||
store.allEvents().mapNotNull { it.payload as? ConceptPromotedEvent }.toList()
|
||||
|
||||
@Test
|
||||
fun `promotes a fingerprint that crossed the threshold and injects it into L3`(): Unit = runBlocking {
|
||||
validatedFix("fp1", "sa")
|
||||
validatedFix("fp1", "sb")
|
||||
validatedFix("fp1", "sc")
|
||||
|
||||
service.runOnce()
|
||||
|
||||
val promoted = promotedEvents()
|
||||
assertEquals(1, promoted.size)
|
||||
assertEquals("fp1", promoted.single().fingerprint)
|
||||
assertEquals(3, promoted.single().occurrences)
|
||||
|
||||
val hits = l3.query(L3Query(vector = FloatArray(8) { 1f }, k = 5))
|
||||
assertTrue(hits.any { it.entry.turnId == "concept:fp1" }) { "concept not injected into L3" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not promote below the threshold`(): Unit = runBlocking {
|
||||
validatedFix("fp1", "sa")
|
||||
validatedFix("fp1", "sb")
|
||||
|
||||
service.runOnce()
|
||||
|
||||
assertTrue(promotedEvents().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated runs do not re-promote the same fingerprint`(): Unit = runBlocking {
|
||||
validatedFix("fp1", "sa")
|
||||
validatedFix("fp1", "sb")
|
||||
validatedFix("fp1", "sc")
|
||||
|
||||
service.runOnce()
|
||||
service.runOnce()
|
||||
|
||||
assertEquals(1, promotedEvents().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.correx.apps.server.git
|
||||
|
||||
import com.correx.core.config.GitConfig
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GitRunBranchTransportTest {
|
||||
|
||||
@Test
|
||||
fun `creates a run branch, pushes it, and records the pushed ref`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("correx-git-transport")
|
||||
val remote = Files.createTempDirectory("correx-git-remote")
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.name", "Test User")
|
||||
git(root, "config", "user.email", "test@example.test")
|
||||
root.resolve("README.md").writeText("base\n")
|
||||
git(root, "add", "README.md")
|
||||
git(root, "commit", "-m", "base")
|
||||
git(remote, "init", "--bare")
|
||||
git(root, "remote", "add", "origin", remote.toString())
|
||||
git(root, "push", "origin", "main")
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
val sessionId = SessionId("git-transport")
|
||||
val transport = GitRunBranchTransport(
|
||||
store,
|
||||
GitConfig(enabled = true, remote = "origin", baseBranch = "main"),
|
||||
)
|
||||
|
||||
transport.onRunBranch(sessionId, root) {
|
||||
root.resolve("agent-output.txt").writeText("generated\n")
|
||||
}
|
||||
|
||||
assertEquals("run/git-transport", git(root, "branch", "--show-current").trim())
|
||||
assertTrue(git(root, "ls-remote", "--heads", "origin", "run/git-transport").isNotBlank())
|
||||
val pushed = store.read(sessionId).single().payload as RunBranchPushedEvent
|
||||
assertEquals("run/git-transport", pushed.branch)
|
||||
assertTrue(pushed.baseSha.isNotBlank())
|
||||
assertEquals(git(root, "rev-parse", "HEAD").trim(), pushed.headSha)
|
||||
}
|
||||
|
||||
private fun git(root: Path, vararg args: String): String {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
check(process.waitFor(20, TimeUnit.SECONDS)) { "git ${args.joinToString(" ")} timed out" }
|
||||
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: $output" }
|
||||
return output
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -87,7 +87,7 @@ class ArchitectContradictionCheckerTest {
|
||||
val store = CannedL3MemoryStore(
|
||||
listOf(
|
||||
// Above threshold but a repo-map entry, not a decision — must be excluded.
|
||||
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"),
|
||||
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:v2:/repo:abc"),
|
||||
),
|
||||
)
|
||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||
|
||||
+4
-4
@@ -19,8 +19,8 @@ class L3RepoKnowledgeRetrieverTest {
|
||||
@Test
|
||||
fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking {
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
|
||||
|
||||
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
|
||||
.retrieve(SessionId("s"), "anything", 10)
|
||||
@@ -31,8 +31,8 @@ class L3RepoKnowledgeRetrieverTest {
|
||||
@Test
|
||||
fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking {
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h1", "repo/A.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo:", "repo/B.kt", vec, 0L))
|
||||
|
||||
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
|
||||
.retrieve(SessionId("s"), "anything", 10)
|
||||
|
||||
+4
-4
@@ -118,7 +118,7 @@ class ProjectMemoryServiceReuseTest {
|
||||
svc.indexAndRecord(sessionId, "/repo")
|
||||
|
||||
assertTrue(
|
||||
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"),
|
||||
l3.existsByTurnIdPrefix("repomap:v2:/repo:git:hash1"),
|
||||
"entries should be embedded with stateKey tag",
|
||||
)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class ProjectMemoryServiceReuseTest {
|
||||
val embedder = ConstantEmbedderReuse()
|
||||
|
||||
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
|
||||
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
|
||||
// The versioned trailing-':' delimiter on the repomap tag must keep their entries from bleeding
|
||||
// across roots: "/repo" must NOT match tags written for "/repo2".
|
||||
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
|
||||
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
|
||||
@@ -142,8 +142,8 @@ class ProjectMemoryServiceReuseTest {
|
||||
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
|
||||
|
||||
// existsByTurnIdPrefix with the delimiter must not match the other root.
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo:"), "tag for /repo should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo2:"), "tag for /repo2 should exist")
|
||||
|
||||
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
|
||||
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
|
||||
|
||||
@@ -41,6 +41,40 @@ class RepoMapIndexerTest {
|
||||
assertTrue(entries.first { it.path == "Old.kt" }.symbols.contains("Old"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source descriptor retains package imports and leading purpose comment`(@TempDir root: Path) {
|
||||
root.resolve("src").createDirectories()
|
||||
root.resolve("src/Context.kt").writeText(
|
||||
"""
|
||||
/** Builds the context packing pipeline for stage inference. */
|
||||
package com.correx.context
|
||||
import com.correx.events.EventStore
|
||||
class DefaultContextPackBuilder
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val entry = RepoMapIndexer().index(root).single()
|
||||
|
||||
assertTrue(entry.descriptor.contains("module com.correx.context"))
|
||||
assertTrue(entry.descriptor.contains("EventStore"))
|
||||
assertTrue(entry.descriptor.contains("context packing pipeline"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source descriptor never uses a declaration as a purpose comment`(@TempDir root: Path) {
|
||||
root.resolve("Plain.kt").writeText(
|
||||
"""
|
||||
package com.correx.example
|
||||
import com.correx.events.EventStore
|
||||
class ALongEnoughDeclarationToHaveLookedLikeAComment
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val entry = RepoMapIndexer().index(root).single()
|
||||
|
||||
assertFalse(entry.descriptor.contains("class ALongEnough"), entry.descriptor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts GDScript top-level symbols`(@TempDir root: Path) {
|
||||
root.resolve("player.gd").writeText(
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.correx.apps.server.ws
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.configureServer
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.ProviderRegistry
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.apps.server.registry.WorkflowSummary
|
||||
import com.correx.apps.server.undo.SessionUndoService
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.inference.DefaultInferenceRouter
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||
import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.infrastructure.InfrastructureModule
|
||||
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
||||
import com.correx.infrastructure.inference.commons.UnavailableProbe
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Covers task #190: a per-session `/sessions/{id}/stream` WS client must observe events that
|
||||
* fire *after* connect (e.g. ApprovalRequired for headless CLI --auto-approve), not just the
|
||||
* replay-on-connect snapshot. Also covers cleanup on disconnect.
|
||||
*/
|
||||
class SessionStreamHandlerTest {
|
||||
|
||||
private val protocolJson = Json { classDiscriminator = "type"; ignoreUnknownKeys = true }
|
||||
private fun decode(text: String): ServerMessage = protocolJson.decodeFromString(text)
|
||||
|
||||
private val noopArtifactStore: ArtifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? = null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
|
||||
private val noopProviderRegistry: ProviderRegistry = object : ProviderRegistry {
|
||||
override fun listAll() = emptyList<InferenceProvider>()
|
||||
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
|
||||
}
|
||||
|
||||
private fun buildWorkflowRegistry(graph: WorkflowGraph): WorkflowRegistry = object : WorkflowRegistry {
|
||||
override fun listAll() = listOf(WorkflowSummary(graph.id, description = ""))
|
||||
override fun find(workflowId: String) = if (workflowId == graph.id) graph else null
|
||||
}
|
||||
|
||||
private fun minimalGraph(): WorkflowGraph = WorkflowGraph(
|
||||
id = "session-stream-test-workflow",
|
||||
stages = mapOf(StageId("s1") to StageConfig(allowedTools = emptySet())),
|
||||
transitions = emptySet(),
|
||||
start = StageId("s1"),
|
||||
)
|
||||
|
||||
private fun buildModule(tempDir: Path): ServerModule {
|
||||
val eventStore = InMemoryEventStore()
|
||||
val provider = InfrastructureModule.createLlamaCppProvider(
|
||||
modelId = "test-model",
|
||||
modelPath = "/dev/null",
|
||||
baseUrl = "http://127.0.0.1:1",
|
||||
)
|
||||
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
|
||||
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||
|
||||
val toolConfig = com.correx.infrastructure.tools.ToolConfig(
|
||||
shell = com.correx.infrastructure.tools.ShellConfig(
|
||||
enabled = false, allowedExecutables = emptySet(), workingDir = tempDir,
|
||||
),
|
||||
fileRead = com.correx.infrastructure.tools.FileReadConfig(
|
||||
enabled = false, allowedPaths = setOf(tempDir),
|
||||
),
|
||||
fileWrite = com.correx.infrastructure.tools.FileWriteConfig(
|
||||
enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir,
|
||||
),
|
||||
fileEdit = com.correx.infrastructure.tools.FileEditConfig(
|
||||
enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir,
|
||||
),
|
||||
)
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
|
||||
val eventDispatcher = com.correx.core.events.EventDispatcher(eventStore)
|
||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||
registry = toolRegistry,
|
||||
eventDispatcher = eventDispatcher,
|
||||
workDir = tempDir,
|
||||
artifactStore = null,
|
||||
)
|
||||
|
||||
val engines = OrchestratorEngines(
|
||||
transitionResolver = com.correx.core.transitions.resolution.DefaultTransitionResolver {
|
||||
condition, ctx -> condition.evaluate(ctx)
|
||||
},
|
||||
contextPackBuilder = com.correx.core.context.builder.DefaultContextPackBuilder(
|
||||
com.correx.core.context.compression.DefaultContextCompressor(),
|
||||
),
|
||||
inferenceRouter = inferenceRouter,
|
||||
validationPipeline = com.correx.core.validation.pipeline.ValidationPipeline(validators = emptyList()),
|
||||
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
|
||||
riskAssessor = com.correx.core.risk.DefaultRiskAssessor(),
|
||||
toolRegistry = toolRegistry,
|
||||
toolExecutor = toolExecutor,
|
||||
)
|
||||
|
||||
val repositories = OrchestratorRepositories(
|
||||
eventStore = eventStore,
|
||||
inferenceRepository = com.correx.core.inference.InferenceRepository(
|
||||
DefaultEventReplayer(eventStore, com.correx.core.inference.InferenceProjector()),
|
||||
),
|
||||
orchestrationRepository = OrchestrationRepository(
|
||||
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||
),
|
||||
sessionRepository = DefaultSessionRepository(
|
||||
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
|
||||
),
|
||||
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
|
||||
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
|
||||
)
|
||||
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||
artifactStore = noopArtifactStore,
|
||||
tokenizer = provider.tokenizer,
|
||||
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
|
||||
)
|
||||
|
||||
val routerFacade = InfrastructureModule.createTalkieFacade(
|
||||
eventStore = eventStore,
|
||||
inferenceRouter = inferenceRouter,
|
||||
config = com.correx.core.talkie.model.TalkieConfig(),
|
||||
tokenizer = provider.tokenizer,
|
||||
)
|
||||
|
||||
val sessionUndoService = SessionUndoService(
|
||||
eventStore = eventStore,
|
||||
artifactStore = noopArtifactStore,
|
||||
bootRoots = setOf(tempDir),
|
||||
)
|
||||
|
||||
return ServerModule(
|
||||
orchestrator = orchestrator,
|
||||
eventStore = eventStore,
|
||||
artifactStore = noopArtifactStore,
|
||||
sessionRepository = repositories.sessionRepository,
|
||||
workflowRegistry = buildWorkflowRegistry(minimalGraph()),
|
||||
providerRegistry = noopProviderRegistry,
|
||||
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
|
||||
routerFacade = routerFacade,
|
||||
orchestrationRepository = repositories.orchestrationRepository,
|
||||
approvalRepository = repositories.approvalRepository,
|
||||
toolRegistry = toolRegistry,
|
||||
sessionUndoService = sessionUndoService,
|
||||
resourceProbe = UnavailableProbe,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalRequired fired after connect reaches the session stream socket`(@TempDir tempDir: Path) {
|
||||
val module = buildModule(tempDir)
|
||||
module.start()
|
||||
val sessionId = SessionId("sid-190")
|
||||
|
||||
testApplication {
|
||||
application { configureServer(module) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket("/sessions/${sessionId.value}/stream") {
|
||||
// No lastEventId query param -> no replay, just registerClient() then the read loop.
|
||||
delay(100)
|
||||
|
||||
// Fire an ApprovalRequestedEvent through the *same* live fan-out ServerModule.start()
|
||||
// wires up (subscribeAll -> approvalCoordinator.onApprovalRequested -> broadcast()),
|
||||
// i.e. exactly what a gate firing mid-session would do post-connect.
|
||||
module.approvalCoordinator.onApprovalRequested(
|
||||
ApprovalRequestedEvent(
|
||||
requestId = com.correx.core.events.types.ApprovalRequestId("req-190"),
|
||||
tier = Tier.T2,
|
||||
validationReportId = ValidationReportId("vr-190"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
|
||||
val frame = withTimeout(3_000L) {
|
||||
var text: String? = null
|
||||
while (text == null) {
|
||||
val f = incoming.receive()
|
||||
if (f is Frame.Text) text = f.readText()
|
||||
}
|
||||
text
|
||||
}
|
||||
val decoded = decode(frame)
|
||||
assertNotNull(decoded)
|
||||
assertTrue(decoded is ServerMessage.ApprovalRequired, "expected ApprovalRequired, got $decoded")
|
||||
assertTrue((decoded as ServerMessage.ApprovalRequired).requestId.value == "req-190")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disconnected socket does not block a broadcast to a later-connected sibling`(@TempDir tempDir: Path) {
|
||||
// Proves the SessionStreamHandler.handle() finally block actually deregisters the closed
|
||||
// socket from ApprovalCoordinator (3559ea67-style cleanup): with two clients on the same
|
||||
// session, closing the first and then firing an approval must still deliver cleanly to the
|
||||
// second - a stale/dead entry left in sessionClients would surface as a delivery failure
|
||||
// log (8d7c827e path) but must never throw or stall the broadcast.
|
||||
val module = buildModule(tempDir)
|
||||
module.start()
|
||||
val sessionId = SessionId("sid-190-disconnect")
|
||||
|
||||
testApplication {
|
||||
application { configureServer(module) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket("/sessions/${sessionId.value}/stream") {
|
||||
delay(100)
|
||||
}
|
||||
// First socket closed on exiting the block; its `finally { unregisterClient(...) }` runs.
|
||||
delay(200)
|
||||
|
||||
client.webSocket("/sessions/${sessionId.value}/stream") {
|
||||
delay(100)
|
||||
|
||||
module.approvalCoordinator.onApprovalRequested(
|
||||
ApprovalRequestedEvent(
|
||||
requestId = com.correx.core.events.types.ApprovalRequestId("req-190-b"),
|
||||
tier = Tier.T2,
|
||||
validationReportId = ValidationReportId("vr-190-b"),
|
||||
riskSummaryId = null,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
|
||||
val frame = withTimeout(3_000L) {
|
||||
var text: String? = null
|
||||
while (text == null) {
|
||||
val f = incoming.receive()
|
||||
if (f is Frame.Text) text = f.readText()
|
||||
}
|
||||
text
|
||||
}
|
||||
val decoded = decode(frame)
|
||||
assertTrue(decoded is ServerMessage.ApprovalRequired)
|
||||
assertTrue((decoded as ServerMessage.ApprovalRequired).requestId.value == "req-190-b")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
|
||||
- Config is read from disk; it is not event-sourced. Do not add event/state/reducer structures here.
|
||||
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
|
||||
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
|
||||
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
|
||||
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ object ConfigLoader {
|
||||
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
|
||||
private const val DEFAULT_RETRIEVAL_K = 5
|
||||
private const val DEFAULT_TOKEN_BUDGET = 4096
|
||||
private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192
|
||||
private const val DEFAULT_MODEL_CONTEXT_SIZE = 24_576
|
||||
private const val DEFAULT_PROJECT_MEMORY_K = 5
|
||||
private const val DEFAULT_PROJECT_MAX_DEPTH = 4
|
||||
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
|
||||
@@ -265,9 +265,11 @@ object ConfigLoader {
|
||||
val providers = mutableListOf<MutableMap<String, Any>>()
|
||||
val models = mutableListOf<MutableMap<String, Any>>()
|
||||
val artifacts = mutableListOf<MutableMap<String, Any>>()
|
||||
val mcpServers = mutableListOf<MutableMap<String, Any>>()
|
||||
var currentProvider: MutableMap<String, Any>? = null
|
||||
var currentModel: MutableMap<String, Any>? = null
|
||||
var currentArtifact: MutableMap<String, Any>? = null
|
||||
var currentMcp: MutableMap<String, Any>? = null
|
||||
|
||||
// Flush any open array-of-table entry into its list. Called whenever a new
|
||||
// table header (array or section) starts, so the previous entry is committed.
|
||||
@@ -275,9 +277,11 @@ object ConfigLoader {
|
||||
currentProvider?.let { providers.add(it) }
|
||||
currentModel?.let { models.add(it) }
|
||||
currentArtifact?.let { artifacts.add(it) }
|
||||
currentMcp?.let { mcpServers.add(it) }
|
||||
currentProvider = null
|
||||
currentModel = null
|
||||
currentArtifact = null
|
||||
currentMcp = null
|
||||
}
|
||||
|
||||
for ((lineNum, line) in lines.withIndex()) {
|
||||
@@ -302,6 +306,11 @@ object ConfigLoader {
|
||||
currentArtifact = mutableMapOf()
|
||||
currentSection = ""
|
||||
}
|
||||
trimmed == "[[mcp]]" -> {
|
||||
flushTables()
|
||||
currentMcp = mutableMapOf()
|
||||
currentSection = ""
|
||||
}
|
||||
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
|
||||
// Parse section headers like [server] or [tools.shell]
|
||||
flushTables()
|
||||
@@ -320,10 +329,12 @@ object ConfigLoader {
|
||||
val provider = currentProvider
|
||||
val model = currentModel
|
||||
val artifact = currentArtifact
|
||||
val mcp = currentMcp
|
||||
when {
|
||||
provider != null -> provider[key] = parsedValue
|
||||
model != null -> model[key] = parsedValue
|
||||
artifact != null -> artifact[key] = parsedValue
|
||||
mcp != null -> mcp[key] = parsedValue
|
||||
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +345,7 @@ object ConfigLoader {
|
||||
// Don't forget the last array-of-table entry if file ends with one
|
||||
flushTables()
|
||||
|
||||
return buildConfig(sections, providers, models, artifacts)
|
||||
return buildConfig(sections, providers, models, artifacts, mcpServers)
|
||||
}
|
||||
|
||||
private fun parseValue(valueStr: String, lineNum: Int): Any {
|
||||
@@ -452,6 +463,7 @@ object ConfigLoader {
|
||||
providersList: List<Map<String, Any>> = emptyList(),
|
||||
modelsList: List<Map<String, Any>> = emptyList(),
|
||||
artifactsList: List<Map<String, Any>> = emptyList(),
|
||||
mcpList: List<Map<String, Any>> = emptyList(),
|
||||
): CorrexConfig {
|
||||
val serverSection = sections["server"] ?: emptyMap()
|
||||
val tuiSection = sections["tui"] ?: emptyMap()
|
||||
@@ -735,6 +747,28 @@ object ConfigLoader {
|
||||
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
|
||||
)
|
||||
|
||||
val mcp = mcpList.mapNotNull { mcpMap ->
|
||||
val id = asStringOrNull(mcpMap["id"]) ?: return@mapNotNull null
|
||||
val command = asStringList(mcpMap["command"])
|
||||
if (command.isEmpty()) return@mapNotNull null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val env = (mcpMap["env"] as? Map<String, Any>)?.mapValues { it.value.toString() } ?: emptyMap()
|
||||
McpServerConfig(
|
||||
id = id,
|
||||
command = command,
|
||||
env = env,
|
||||
tier = asString(mcpMap["tier"], "T2"),
|
||||
)
|
||||
}
|
||||
|
||||
val gitSection = sections["git"] ?: emptyMap()
|
||||
val git = GitConfig(
|
||||
enabled = asBoolean(gitSection["enabled"], false),
|
||||
remote = asString(gitSection["remote"], "origin"),
|
||||
baseBranch = asString(gitSection["base_branch"], "main"),
|
||||
author = asString(gitSection["author"], ""),
|
||||
)
|
||||
|
||||
return CorrexConfig(
|
||||
server = server,
|
||||
tui = tui,
|
||||
@@ -749,6 +783,8 @@ object ConfigLoader {
|
||||
personalization = personalization,
|
||||
orchestration = orchestration,
|
||||
sampling = sampling,
|
||||
git = git,
|
||||
mcp = mcp,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,34 @@ data class CorrexConfig(
|
||||
val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
|
||||
val sampling: SamplingConfig = SamplingConfig(),
|
||||
val health: HealthConfig = HealthConfig(),
|
||||
val git: GitConfig = GitConfig(),
|
||||
val mcp: List<McpServerConfig> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* An MCP server to mount at startup. Its `tools/list` becomes Correx tools (`mcp__<id>__<name>`),
|
||||
* gated at [tier] (default T2 = approval) since an external tool's side effects are opaque.
|
||||
*/
|
||||
@Serializable
|
||||
data class McpServerConfig(
|
||||
val id: String,
|
||||
val command: List<String>,
|
||||
val env: Map<String, String> = emptyMap(),
|
||||
val tier: String = "T2",
|
||||
)
|
||||
|
||||
/**
|
||||
* Optional transport for a server-owned checkout. Each run executes on a `run/<sessionId>` branch
|
||||
* based on [baseBranch] and pushes that branch on terminal completion; the working directory stays
|
||||
* a real local path, never a remote URL or mounted client filesystem.
|
||||
*/
|
||||
@Serializable
|
||||
data class GitConfig(
|
||||
val enabled: Boolean = false,
|
||||
val remote: String = "origin",
|
||||
val baseBranch: String = "main",
|
||||
/** Optional Git author value, for example `Correx <correx@example.invalid>`. */
|
||||
val author: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -283,7 +311,8 @@ data class L3Config(
|
||||
data class ModelConfig(
|
||||
val id: String,
|
||||
val modelPath: String,
|
||||
val contextSize: Int = 8192,
|
||||
/** Default window for implementation stages; leave headroom above their 24K prompt budget. */
|
||||
val contextSize: Int = 24_576,
|
||||
val params: Map<String, String> = emptyMap(),
|
||||
val capabilities: Map<String, Double> = emptyMap(),
|
||||
)
|
||||
|
||||
@@ -109,6 +109,12 @@ object CorrexConfigWriter {
|
||||
cfg.sampling.minP?.let { b.kv("min_p", it) }
|
||||
cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", it) }
|
||||
|
||||
b.section("git")
|
||||
b.kv("enabled", cfg.git.enabled)
|
||||
b.kv("remote", str(cfg.git.remote))
|
||||
b.kv("base_branch", str(cfg.git.baseBranch))
|
||||
b.kv("author", str(cfg.git.author))
|
||||
|
||||
b.section("personalization")
|
||||
b.kv("enabled", cfg.personalization.enabled)
|
||||
b.kv("learn", cfg.personalization.learn)
|
||||
|
||||
@@ -390,6 +390,31 @@ class ConfigLoaderTest {
|
||||
assertEquals(10001, result.modelsSettings.port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToml parses mcp array-of-tables with command list and env`() {
|
||||
val toml = """
|
||||
[[mcp]]
|
||||
id = "codebase-memory"
|
||||
command = ["codebase-memory-mcp", "serve"]
|
||||
env = { RUST_LOG = "info" }
|
||||
|
||||
[[mcp]]
|
||||
id = "other"
|
||||
command = ["other-server"]
|
||||
""".trimIndent()
|
||||
|
||||
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
|
||||
parseTomlMethod.isAccessible = true
|
||||
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||
|
||||
assertEquals(2, result.mcp.size)
|
||||
assertEquals("codebase-memory", result.mcp[0].id)
|
||||
assertEquals(listOf("codebase-memory-mcp", "serve"), result.mcp[0].command)
|
||||
assertEquals("info", result.mcp[0].env["RUST_LOG"])
|
||||
assertEquals("T2", result.mcp[0].tier)
|
||||
assertEquals(listOf("other-server"), result.mcp[1].command)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToml returns empty models list and default modelsSettings when sections absent`() {
|
||||
val toml = """
|
||||
@@ -434,7 +459,7 @@ class ConfigLoaderTest {
|
||||
assertEquals(1, result.models.size)
|
||||
assertEquals("local-model", result.models[0].id)
|
||||
assertEquals("/models/local.gguf", result.models[0].modelPath)
|
||||
assertEquals(8192, result.models[0].contextSize)
|
||||
assertEquals(24_576, result.models[0].contextSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -37,6 +37,7 @@ class CorrexConfigWriterTest {
|
||||
),
|
||||
personalization = PersonalizationConfig(enabled = true, learn = true),
|
||||
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
|
||||
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
|
||||
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
|
||||
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
|
||||
providers = listOf(
|
||||
|
||||
@@ -27,6 +27,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
|
||||
- **SILENT FAILURE TRAP**: After adding any `EventPayload` subclass, immediately add it to `Serialization.kt` `eventModule` block. Run `./gradlew check` to verify. Tests may pass without it but runtime replay will fail silently.
|
||||
- `AnyMapSerializer` — custom serializer for `Map<String, Any?>`; use it for dynamic payloads, don't roll another.
|
||||
- Event classes are `@Serializable data class` with no mutable state. No methods beyond data accessors.
|
||||
- `RunBranchPushedEvent` records an optional server Git transport push only after it succeeds; its branch/base/head SHAs are observations, not values replay recalculates.
|
||||
- `RepoMapEntry.descriptor` is a bounded source-purpose observation recorded with the repo map and used when constructing semantic L3 embeddings.
|
||||
- Do not add domain logic to events. They are records, not actors.
|
||||
- `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection.
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A validated failure→fix pattern the heuristic concept compiler has seen resolved reliably enough to
|
||||
* promote (design 2026-07-12-acr-concept-compiler.md). The only "write" the compiler makes: a
|
||||
* deterministic rule firing — a [fingerprint] whose validated-fix count crossed the promotion
|
||||
* threshold, never contradicted — so it needs no assessor gate (invariant #3/#7 hold trivially:
|
||||
* nothing model-proposed reaches state here).
|
||||
*
|
||||
* Read-only over the log otherwise: the promotion is derivable from the RetryAttempted→StageCompleted
|
||||
* stream, and this event makes the promotion idempotent under replay (the compiler folds already-emitted
|
||||
* fingerprints into its promoted set and never re-promotes). Best-effort injected into L3 as a
|
||||
* retrieval-on-demand concept; the L3 write is non-authoritative (invariant #6) — this event is truth.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ConceptPromoted")
|
||||
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) — 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
|
||||
@@ -16,6 +16,8 @@ data class ContractAssertionResult(
|
||||
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
|
||||
val evaluator: String,
|
||||
val passed: Boolean,
|
||||
/** True when this assertion is intentionally deferred to another authoritative gate. */
|
||||
val skipped: Boolean = false,
|
||||
/** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
|
||||
val evidence: String,
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ data class WorkflowFailedEvent(
|
||||
val retryExhausted: Boolean,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Observation recorded only after the optional server Git transport has pushed a terminal run
|
||||
* branch. This keeps the reviewable branch reference replayable without re-running Git.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("RunBranchPushed")
|
||||
data class RunBranchPushedEvent(
|
||||
val sessionId: SessionId,
|
||||
val branch: String,
|
||||
val baseSha: String,
|
||||
val headSha: String,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Records that the operator approved access to a specific path OUTSIDE the workspace root
|
||||
* (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace
|
||||
@@ -155,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
|
||||
|
||||
@@ -11,6 +11,8 @@ data class RepoMapEntry(
|
||||
val path: String,
|
||||
val score: Double,
|
||||
val symbols: List<String> = emptyList(),
|
||||
/** Bounded deterministic source-purpose text used for semantic repo retrieval. */
|
||||
val descriptor: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+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
|
||||
@@ -14,6 +14,7 @@ import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
@@ -64,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
|
||||
@@ -88,6 +92,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
import com.correx.core.events.events.TaskClaimedEvent
|
||||
import com.correx.core.events.events.TaskReleasedEvent
|
||||
@@ -128,6 +133,7 @@ val eventModule = SerializersModule {
|
||||
subclass(SessionNamedEvent::class)
|
||||
subclass(StageFailedEvent::class)
|
||||
subclass(StageCompletedEvent::class)
|
||||
subclass(ConceptPromotedEvent::class)
|
||||
subclass(TransitionExecutedEvent::class)
|
||||
subclass(ApprovalRequestedEvent::class)
|
||||
subclass(ApprovalDecisionResolvedEvent::class)
|
||||
@@ -149,11 +155,15 @@ val eventModule = SerializersModule {
|
||||
subclass(OrchestrationResumedEvent::class)
|
||||
subclass(OrchestrationPausedEvent::class)
|
||||
subclass(WorkflowStartedEvent::class)
|
||||
subclass(RunBranchPushedEvent::class)
|
||||
subclass(WorkflowFailedEvent::class)
|
||||
subclass(WorkflowCompletedEvent::class)
|
||||
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)
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.ConceptPromotedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConceptPromotedEventSerializationTest {
|
||||
|
||||
private val sample = ConceptPromotedEvent(
|
||||
sessionId = SessionId("__concept_compiler__"),
|
||||
fingerprint = "1a2b3c",
|
||||
gate = "lint",
|
||||
conceptText = "Recurring lint failure (validated-fixed 3x across sessions): detekt: MagicNumber.",
|
||||
occurrences = 3,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `round-trips as polymorphic EventPayload`() {
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"ConceptPromoted\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
|
||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
||||
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
|
||||
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
|
||||
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
|
||||
- Every stage receives a small curated operating-guidance system entry: verify observed state, create required project setup, and resolve necessary scope edges without re-deliberating.
|
||||
- `JournalCompactionService` — triggers journal compaction and emits `JournalCompactedEvent`.
|
||||
- `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring.
|
||||
- `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning.
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
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
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
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 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 class went on to complete = validated failure→fix.
|
||||
val validatedFixes: Int = 0,
|
||||
// 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 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(),
|
||||
// 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
|
||||
* contradicted, not already promoted. Pure — the caller (a service) appends the ConceptPromotedEvent.
|
||||
*/
|
||||
fun promotable(threshold: Int = DEFAULT_PROMOTION_THRESHOLD): List<ConceptCluster> =
|
||||
clusters.values.filter {
|
||||
it.classKey !in promoted && !it.contradicted && it.validatedFixes >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection over the event log that clusters validated failure→fix pairs by
|
||||
* [RetryAttemptedEvent.fingerprint] (gate-agnostic — contract/review/plan-compile/lint/static all
|
||||
* flow through the same retry event) and tracks which fingerprints are promotable. Fed the full
|
||||
* cross-session stream by its driver; the fold is deterministic and replayable (invariant #8).
|
||||
*
|
||||
* Signal: a stage emits RetryAttemptedEvent(fingerprint=fp) then StageCompleted ⇒ fp was validated-fixed.
|
||||
* A StageFailed/WorkflowFailed while fp is still open ⇒ the fix didn't hold (contradiction).
|
||||
*/
|
||||
class ConceptCompilerProjection : Projection<ConceptCompilerState> {
|
||||
|
||||
override fun initial() = ConceptCompilerState()
|
||||
|
||||
override fun apply(state: ConceptCompilerState, event: StoredEvent): ConceptCompilerState =
|
||||
when (val p = event.payload) {
|
||||
is RetryAttemptedEvent -> {
|
||||
val key = "${p.sessionId.value}/${p.stageId.value}"
|
||||
val sig = p.failureReason.lineSequence().firstOrNull()?.take(SIGNATURE_MAX)?.trim().orEmpty()
|
||||
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.classKey)
|
||||
else -> state
|
||||
}
|
||||
|
||||
private fun resolve(state: ConceptCompilerState, key: String, fixed: Boolean): ConceptCompilerState {
|
||||
val failure = state.open[key] ?: return state
|
||||
val sessionId = key.substringBefore('/')
|
||||
val existing = state.clusters[failure.classKey]
|
||||
?: ConceptCluster(failure.classKey, failure.fingerprint, failure.gate, failure.signature)
|
||||
val updated = if (fixed) {
|
||||
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.classKey to updated),
|
||||
open = state.open - key,
|
||||
lastWrite = state.lastWrite - sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
// A workflow that failed terminally contradicts every fingerprint still open in that session.
|
||||
private fun failAllInSession(state: ConceptCompilerState, sessionId: String): ConceptCompilerState {
|
||||
val prefix = "$sessionId/"
|
||||
var acc = state
|
||||
state.open.keys.filter { it.startsWith(prefix) }.forEach { acc = resolve(acc, it, fixed = false) }
|
||||
return acc
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SIGNATURE_MAX = 200
|
||||
}
|
||||
}
|
||||
+20
@@ -65,12 +65,32 @@ 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"
|
||||
|
||||
// Gate id for a provably-stuck stage failure loop (Vikunja #78). Detected by repeatedToolFailureLoop
|
||||
// when the SAME tool-failure signature recurs past the tuning limit; routed straight to recovery
|
||||
// (never retried in place) by the step handler before the normal retry path.
|
||||
internal const val STAGE_LOOP_BREAK_GATE = "stage_loop_break"
|
||||
|
||||
internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
|
||||
"execution" to "file_write",
|
||||
"contract" to "file_write",
|
||||
"static_analysis" to "file_write",
|
||||
WORKSPACE_PRECONDITION_GATE to "file_write",
|
||||
)
|
||||
|
||||
// ponytail: trimmed to the single non-redundant sentence after the 2026-07-16 audition
|
||||
// (docs/qa/QA-stage-prompt-audition-results-2026-07-16.md) found the full block performance-neutral —
|
||||
// the read-before-write / verify-before-complete / use-exact-feedback nudges just restate behavior the
|
||||
// gates already enforce, and run 3 proved prose doesn't stop failure loops (deterministic defenses do).
|
||||
// Only the scaffolding-scope boundary encodes a decision no gate makes. Delete outright if a future
|
||||
// multi-fixture audit still shows no clarity value.
|
||||
internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance
|
||||
When the authoritative intent plainly requires project setup, creating its manifest, configuration,
|
||||
or entry file is in scope; do not spend turns re-deciding that settled boundary."""
|
||||
|
||||
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
|
||||
internal fun ticketCategory(gate: String): String = when (gate) {
|
||||
"plan_compile" -> "planning"
|
||||
|
||||
+4
-3
@@ -44,8 +44,9 @@ internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
|
||||
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
|
||||
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
|
||||
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
|
||||
* or null to fall through to the normal per-gate retry path. A stage that already holds the
|
||||
* capability retries in place first; its unchanged-fingerprint retry exhaustion is separately
|
||||
* routed from the step loop, because capability possession does not prove the owner can apply it.
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
|
||||
@@ -56,7 +57,7 @@ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
|
||||
): StepResult? {
|
||||
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
|
||||
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
|
||||
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
|
||||
if (requiredCapability in stageTools) return null // retry in place before no-progress exhaustion
|
||||
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -254,6 +254,25 @@ 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
|
||||
}
|
||||
}
|
||||
// A proven same-signature failure loop (Vikunja #78) must never be retried in place —
|
||||
// retrying is what got us here. Route straight to recovery regardless of capability;
|
||||
// terminal if the graph offers no recovery route.
|
||||
if (result.gate == STAGE_LOOP_BREAK_GATE) {
|
||||
return routeToRecovery(ctx, stageId, result.gate, "file_write", result.reason, refreshedState)
|
||||
?: StepResult.Terminal(
|
||||
failWorkflow(ctx.sessionId, stageId, result.reason, retryExhausted = true),
|
||||
)
|
||||
}
|
||||
// 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.
|
||||
@@ -269,6 +288,20 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
when (gateDecision) {
|
||||
RetryDecision.Retry -> Unit // retry — loop and re-execute
|
||||
RetryDecision.Exhausted -> {
|
||||
// A stage may hold the nominal capability yet repeatedly fail to apply it
|
||||
// (scope paralysis / frozen ReAct loop). Exhaustion is only returned for an
|
||||
// unchanged fingerprint, so hand that no-progress dead-end to the
|
||||
// intent-holder rather than declaring the workflow failed in place.
|
||||
GATE_REQUIRED_CAPABILITY[result.gate]?.let { capability ->
|
||||
routeToRecovery(
|
||||
ctx,
|
||||
stageId,
|
||||
result.gate,
|
||||
capability,
|
||||
result.reason,
|
||||
refreshedState,
|
||||
)?.let { return it }
|
||||
}
|
||||
decideGateExhaustion(
|
||||
ctx, stageId, result.gate, result.reason, refreshedState,
|
||||
)?.let { return it }
|
||||
|
||||
+6
@@ -41,4 +41,10 @@ data class OrchestrationTuning(
|
||||
val recoveryRouteBudget: Int = 2,
|
||||
/** Budget for tier-2 intent-holder arbiter re-routing. */
|
||||
val intentRouteBudget: Int = 2,
|
||||
/**
|
||||
* Cumulative same-signature tool failures within a stage before it's broken out of the loop and
|
||||
* routed to recovery (Vikunja #78). Unlike the consecutive read/rejection nudges, this survives
|
||||
* interleaved successes — it counts a normalized failure signature across the whole stage.
|
||||
*/
|
||||
val stageFailureLoopLimit: Int = 6,
|
||||
)
|
||||
|
||||
+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
|
||||
+31
-1
@@ -81,6 +81,30 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
// 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,
|
||||
content = CURATED_STAGE_OPERATING_GUIDANCE,
|
||||
sourceType = "operatingGuidance",
|
||||
sourceId = "curated-v1",
|
||||
tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE),
|
||||
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
|
||||
@@ -239,7 +263,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + 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,
|
||||
@@ -390,6 +414,12 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
inferenceResult.response.toolCalls, stageConfig, effectives,
|
||||
approvalModeFor(session.state.boundProfile?.approvalMode),
|
||||
inferenceResult.response.reasoning)
|
||||
repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
|
||||
return StageExecutionResult.Failure(reason, retryable = true, gate = WORKSPACE_PRECONDITION_GATE)
|
||||
}
|
||||
repeatedToolFailureLoop(sessionId, stageId, tuning.stageFailureLoopLimit)?.let { reason ->
|
||||
return StageExecutionResult.Failure(reason, retryable = true, gate = STAGE_LOOP_BREAK_GATE)
|
||||
}
|
||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||
if (fatalEntry != null) {
|
||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||
|
||||
+1
@@ -258,6 +258,7 @@ internal suspend fun SessionOrchestrator.evaluateStageContract(
|
||||
layer = a.layer.name,
|
||||
evaluator = a.evaluator.name,
|
||||
passed = v.passed,
|
||||
skipped = v.skipped,
|
||||
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
|
||||
)
|
||||
}
|
||||
|
||||
+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",
|
||||
)
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
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.ToolExecutionFailedEvent
|
||||
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 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 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 = windowed
|
||||
.mapNotNull { it.payload as? ToolCallAssessedEvent }
|
||||
.filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
|
||||
.filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
|
||||
.mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String }
|
||||
.filter(::isBuildCriticalPath)
|
||||
val repeated = paths.groupingBy { it }.eachCount().entries
|
||||
.firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT }
|
||||
?: return null
|
||||
return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " +
|
||||
"(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
|
||||
}
|
||||
|
||||
/**
|
||||
* Global backstop for a stage stuck retrying the *same* failing action (Vikunja #78). The existing
|
||||
* read-loop and rejection-loop breakers key on CONSECUTIVE rounds, so a single interleaved success
|
||||
* resets their counters — exactly what let the 2026-07-16 audition run-3 thrash `npm install` against
|
||||
* a hallucinated package across 57 inferences. This counts CUMULATIVELY per normalized failure
|
||||
* signature within the stage: once one signature has failed [limit] times, retrying it is provably
|
||||
* pointless, so return a retryable gate failure that the step handler routes straight to recovery.
|
||||
*
|
||||
* Pure fold over recorded events (replay-safe, invariants #8/#9). [ToolExecutionFailedEvent] carries
|
||||
* no stageId, so failures are correlated to the stage via their invocationId → the emitting
|
||||
* [ToolInvocationRequestedEvent] (which does).
|
||||
*
|
||||
* ponytail: cumulative over the whole stage, not windowed per re-entry — a break escalates to
|
||||
* recovery under a bounded budget, so an unfixable loop terminates rather than re-tripping forever.
|
||||
* Add a per-re-entry window only if a legitimate later attempt gets cut short.
|
||||
*/
|
||||
internal fun SessionOrchestrator.repeatedToolFailureLoop(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
limit: Int,
|
||||
): String? = detectRepeatedToolFailure(eventStore.read(sessionId), stageId, limit)
|
||||
|
||||
/** Pure core of [repeatedToolFailureLoop] — a fold over recorded events (unit-tested in isolation). */
|
||||
internal fun detectRepeatedToolFailure(
|
||||
events: List<StoredEvent>,
|
||||
stageId: StageId,
|
||||
limit: Int,
|
||||
): String? {
|
||||
val stageInvocations = events
|
||||
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId }
|
||||
.map { it.invocationId }
|
||||
.toSet()
|
||||
val repeated = events
|
||||
.mapNotNull { it.payload as? ToolExecutionFailedEvent }
|
||||
.filter { it.invocationId in stageInvocations }
|
||||
// Collapse to a stable signature so equivalent retries group together: drop digits (package
|
||||
// versions, ports, counts) and punctuation/paths, keep the tool name and alphabetic core.
|
||||
// `npm install @types/vite@4.0.0` 404 and `@types/vite@5` 404 map to the same signature.
|
||||
.groupingBy { failure ->
|
||||
failure.toolName + "|" + failure.reason.lowercase()
|
||||
.replace(Regex("[0-9]+"), " ")
|
||||
.replace(Regex("[^a-z ]+"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.take(FAILURE_SIGNATURE_PREFIX)
|
||||
}
|
||||
.eachCount().entries
|
||||
.firstOrNull { it.value >= limit }
|
||||
?: return null
|
||||
return "stage ${stageId.value} is stuck: the same tool failure recurred ${repeated.value} times " +
|
||||
"(${repeated.key}). Retrying it keeps failing — a materially different action is required."
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 const val FAILURE_SIGNATURE_PREFIX = 80
|
||||
private val BUILD_PREREQUISITE_FILENAMES = setOf(
|
||||
"package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
|
||||
)
|
||||
+12
-5
@@ -85,7 +85,6 @@ internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String =
|
||||
"\nAccepted parameters for '${it.name}': ${it.parametersSchema}"
|
||||
}.orEmpty()
|
||||
|
||||
|
||||
/**
|
||||
* Render a tool result into the consistently-framed, bounded text the model sees. Success output
|
||||
* is run through the tool's [ToolOutputCompressor], then framed with a uniform `[tool exit=N]`
|
||||
@@ -99,20 +98,28 @@ internal fun SessionOrchestrator.toolArgsHint(tool: Tool?): String =
|
||||
internal suspend fun SessionOrchestrator.renderToolResult(toolName: String, tool: Tool?, result: ToolResult): RenderedToolResult =
|
||||
when (result) {
|
||||
is ToolResult.Failure -> RenderedToolResult(
|
||||
if (!result.recoverable) "FATAL: ${result.reason}" else "ERROR: ${result.reason}${toolArgsHint(tool)}",
|
||||
if (!result.recoverable) {
|
||||
"FATAL: ${result.reason}"
|
||||
} else {
|
||||
"ERROR: ${result.reason}${toolArgsHint(tool)}${packageNotFoundAdvisory(result.reason)}"
|
||||
},
|
||||
null,
|
||||
)
|
||||
is ToolResult.Success -> {
|
||||
val compressed = tool?.outputCompressor?.compress(result.output, ToolOutputContext(result.exitCode))
|
||||
?: result.output
|
||||
val header = "[$toolName exit=${result.exitCode}]"
|
||||
if (compressed.length <= TOOL_RESULT_MAX_CHARS) {
|
||||
RenderedToolResult("$header\n$compressed", null)
|
||||
// A package-install 404 usually surfaces as a nonzero-exit Success (the shell ran; the
|
||||
// installer failed). Parse it deterministically and name the offending manifest entry so
|
||||
// the model edits the manifest instead of thrashing the installer (Vikunja #192).
|
||||
val advisory = if (result.exitCode != 0) packageNotFoundAdvisory(result.output) else ""
|
||||
if (compressed.length + advisory.length <= TOOL_RESULT_MAX_CHARS) {
|
||||
RenderedToolResult("$header\n$compressed$advisory", null)
|
||||
} else {
|
||||
// Spill the full RAW output (most complete) so retrieval returns everything, not the
|
||||
// already-compressed preview the model saw.
|
||||
val ref = artifactStore.put(result.output.toByteArray()).value
|
||||
RenderedToolResult(frameTruncatedToolResult(header, compressed, ref), ref)
|
||||
RenderedToolResult(frameTruncatedToolResult(header, compressed, ref) + advisory, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
/**
|
||||
* Deterministic recovery evidence for an unambiguous package-not-found (Vikunja #192). A registry
|
||||
* 404 means the dependency does not exist — retrying the installer, pinning a version, or switching
|
||||
* package managers cannot fix it (the 2026-07-16 audition burned 26 inferences doing exactly that).
|
||||
* Parse the offending name out of npm/yarn/pnpm output and hand back a directive that points at the
|
||||
* *manifest*, not the installer. Empty string when no 404 is detected, so callers concatenate
|
||||
* unconditionally. Pure over the tool output (already recorded on the ToolReceipt) — no new event.
|
||||
*/
|
||||
internal fun packageNotFoundAdvisory(output: String): String {
|
||||
val pkg = PACKAGE_NOT_FOUND_PATTERNS.firstNotNullOfOrNull { it.find(output)?.groupValues?.get(1) }
|
||||
?.replace("%2f", "/", ignoreCase = true) // npm URL-encodes the scope slash
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return ""
|
||||
// Strip a trailing @version so the name matches the manifest key.
|
||||
val name = if (pkg.startsWith("@")) {
|
||||
"@" + pkg.removePrefix("@").substringBefore('@')
|
||||
} else {
|
||||
pkg.substringBefore('@')
|
||||
}
|
||||
return "\nPACKAGE_NOT_FOUND: '$name' does not exist in the registry (404). Do NOT retry the " +
|
||||
"installer, pin a version, or switch package managers — none can create a nonexistent " +
|
||||
"package. Edit the manifest (e.g. package.json) to remove or correct '$name', then reinstall."
|
||||
}
|
||||
|
||||
// Registry-404 shapes across npm/yarn/pnpm. Group 1 is the offending package spec.
|
||||
// npm modern: npm error 404 '@types/vite@^4.0.0' is not in this registry.
|
||||
// npm/GET url: npm error 404 Not Found - GET https://registry.npmjs.org/@types%2fvite - Not found
|
||||
// yarn: error ... https://registry.yarnpkg.com/@types%2fvite: Not found
|
||||
// pnpm: ERR_PNPM_FETCH_404 ... GET https://registry.npmjs.org/@types%2fvite: Not Found - 404
|
||||
private val PACKAGE_NOT_FOUND_PATTERNS = listOf(
|
||||
Regex("""404[^\n']*'([^'\n]+)'"""),
|
||||
Regex("""404[^\n]*?registry\.\S+?/(@?[\w.%-]+(?:/[\w.%-]+)?)"""),
|
||||
)
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
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
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
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
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ConceptCompilerProjectionTest {
|
||||
|
||||
private val projection = ConceptCompilerProjection()
|
||||
private var seq = 0L
|
||||
|
||||
private fun stored(payload: EventPayload, session: String = "s1") = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e${seq++}"),
|
||||
sessionId = SessionId(session),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
private fun retry(fp: String, stage: String = "impl", session: String = "s1", reason: String = "boom\ndetail") =
|
||||
stored(
|
||||
RetryAttemptedEvent(
|
||||
sessionId = SessionId(session),
|
||||
stageId = StageId(stage),
|
||||
attemptNumber = 1,
|
||||
maxAttempts = 3,
|
||||
failureReason = reason,
|
||||
gate = "lint",
|
||||
fingerprint = fp,
|
||||
),
|
||||
session,
|
||||
)
|
||||
|
||||
private fun completed(stage: String = "impl", session: String = "s1") =
|
||||
stored(StageCompletedEvent(SessionId(session), StageId(stage), TransitionId("t")), session)
|
||||
|
||||
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.only.validatedFixes)
|
||||
assertTrue(state.promotable().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three cross-session validated fixes cross the threshold`() {
|
||||
val state = fold(
|
||||
listOf(
|
||||
retry("fp1", session = "sa"), completed(session = "sa"),
|
||||
retry("fp1", session = "sb"), completed(session = "sb"),
|
||||
retry("fp1", session = "sc"), completed(session = "sc"),
|
||||
),
|
||||
)
|
||||
assertEquals(3, state.only.validatedFixes)
|
||||
assertEquals(listOf("lint:boom"), state.promotable().map { it.classKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
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"),
|
||||
retry("fp1", session = "sb"), completed(session = "sb"),
|
||||
retry("fp1", session = "sc"), failed(session = "sc"),
|
||||
),
|
||||
)
|
||||
assertTrue(state.only.contradicted)
|
||||
assertTrue(state.promotable().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WorkflowFailed contradicts every open class in that session`() {
|
||||
val state = fold(
|
||||
listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
assertEquals(2, state.clusters.size)
|
||||
assertTrue(state.clusters.values.all { it.contradicted })
|
||||
}
|
||||
|
||||
@Test
|
||||
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: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.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,
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PackageNotFoundAdvisoryTest {
|
||||
|
||||
@Test
|
||||
fun `npm quoted-spec 404 names the scoped package without its version`() {
|
||||
val out = "npm error code E404\nnpm error 404 '@types/vite@^4.0.0' is not in this registry."
|
||||
val advisory = packageNotFoundAdvisory(out)
|
||||
assertTrue(advisory.contains("'@types/vite'"), advisory)
|
||||
assertTrue(advisory.contains("Edit the manifest"), advisory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `npm GET-url 404 decodes the scope slash`() {
|
||||
val out = "npm error 404 Not Found - GET https://registry.npmjs.org/@types%2fvite - Not found"
|
||||
assertTrue(packageNotFoundAdvisory(out).contains("'@types/vite'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pnpm registry 404 names the package`() {
|
||||
val out = "ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/leftpad-nope: Not Found - 404"
|
||||
assertTrue(packageNotFoundAdvisory(out).contains("'leftpad-nope'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated failures produce no advisory`() {
|
||||
assertTrue(packageNotFoundAdvisory("tsc: type error in App.tsx").isEmpty())
|
||||
assertTrue(packageNotFoundAdvisory("npm warn deprecated foo@1.0.0").isEmpty())
|
||||
}
|
||||
}
|
||||
+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))
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
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.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
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 kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RepeatedToolFailureLoopTest {
|
||||
|
||||
private val stage = StageId("install_dependencies")
|
||||
|
||||
@Test
|
||||
fun `same-signature failures trip the limit even when interleaved with successes`() {
|
||||
// The rejection-loop breaker would reset on the interleaved success; this must not.
|
||||
val events = buildList {
|
||||
repeat(3) { addAll(failure("npm install @types/vite@4.0.0 -> registry 404 not found")) }
|
||||
add(success()) // resets any CONSECUTIVE counter
|
||||
repeat(3) { addAll(failure("npm install @types/vite@5.1.2 -> registry 404 not found")) }
|
||||
}
|
||||
val reason = detectRepeatedToolFailure(events, stage, limit = 6)
|
||||
assertNotNull(reason)
|
||||
assertTrue(reason!!.contains("install_dependencies"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distinct failures below the limit do not trip`() {
|
||||
val events = listOf(
|
||||
failure("npm install a -> 404"),
|
||||
failure("tsc -> type error in App.tsx"),
|
||||
failure("eslint -> no config found"),
|
||||
).flatten()
|
||||
assertNull(detectRepeatedToolFailure(events, stage, limit = 6))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failures from other stages are not counted`() {
|
||||
val other = StageId("scaffold")
|
||||
val events = (1..8).flatMap { failure("npm install x -> 404", stageOf = other) }
|
||||
assertNull(detectRepeatedToolFailure(events, stage, limit = 6))
|
||||
}
|
||||
|
||||
private var seq = 0L
|
||||
private val session = SessionId("s1")
|
||||
|
||||
private fun ev(payload: EventPayload) = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e${seq++}"),
|
||||
sessionId = session,
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
// A failure = the request (carries stageId) + the failure (carries only invocationId), correlated by id.
|
||||
private fun failure(reason: String, stageOf: StageId = stage): List<StoredEvent> {
|
||||
val id = ToolInvocationId("inv-${seq}")
|
||||
val req = ev(
|
||||
ToolInvocationRequestedEvent(
|
||||
invocationId = id, sessionId = session, stageId = stageOf,
|
||||
toolName = "shell", tier = Tier.T1,
|
||||
request = ToolRequest(id, session, stageOf, "shell"),
|
||||
),
|
||||
)
|
||||
return listOf(req, ev(ToolExecutionFailedEvent(id, session, "shell", reason)))
|
||||
}
|
||||
|
||||
private fun success() = ev(
|
||||
ToolInvocationRequestedEvent(
|
||||
invocationId = ToolInvocationId("ok-${seq}"), sessionId = session, stageId = stage,
|
||||
toolName = "shell", tier = Tier.T1,
|
||||
request = ToolRequest(ToolInvocationId("ok-$seq"), session, stage, "shell"),
|
||||
),
|
||||
)
|
||||
}
|
||||
+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,
|
||||
)
|
||||
}
|
||||
+32
-4
@@ -41,10 +41,38 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
|
||||
|
||||
private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> {
|
||||
if (lines.size <= rule.head + rule.tail) return lines
|
||||
val elided = lines.size - rule.head - rule.tail
|
||||
return lines.take(rule.head) +
|
||||
"… $elided lines elided …" +
|
||||
lines.takeLast(rule.tail)
|
||||
val middle = lines.subList(rule.head, lines.size - rule.tail)
|
||||
val salienceRegex = rule.salience?.let { Regex(it, RegexOption.IGNORE_CASE) }
|
||||
val middleOut = if (salienceRegex == null) {
|
||||
listOf("… ${middle.size} lines elided …")
|
||||
} else {
|
||||
renderSalientMiddle(middle, salienceRegex, rule.salienceCap)
|
||||
}
|
||||
return lines.take(rule.head) + middleOut + lines.takeLast(rule.tail)
|
||||
}
|
||||
|
||||
// Walks the elided middle, keeping order and retaining up to [cap] salient lines in place;
|
||||
// runs of dropped lines around them collapse into a single elision marker each, so a decisive
|
||||
// error at line 250 of 500 survives even though the bulk of the log is still bounded.
|
||||
private fun renderSalientMiddle(middle: List<String>, salienceRegex: Regex, cap: Int): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
var elidedCount = 0
|
||||
var keptSalient = 0
|
||||
for (line in middle) {
|
||||
val isSalient = keptSalient < cap && salienceRegex.containsMatchIn(line)
|
||||
if (isSalient) {
|
||||
if (elidedCount > 0) {
|
||||
out += "… $elidedCount lines elided …"
|
||||
elidedCount = 0
|
||||
}
|
||||
out += line
|
||||
keptSalient++
|
||||
} else {
|
||||
elidedCount++
|
||||
}
|
||||
}
|
||||
if (elidedCount > 0) out += "… $elidedCount lines elided …"
|
||||
return out
|
||||
}
|
||||
|
||||
private fun collapseConsecutive(lines: List<String>): List<String> {
|
||||
|
||||
+11
-2
@@ -38,11 +38,20 @@ sealed interface CompressionRule {
|
||||
|
||||
/**
|
||||
* When line count exceeds [head] + [tail], keep the first [head] and last
|
||||
* [tail] lines, replacing the middle with a single elision marker line.
|
||||
* [tail] lines. If [salience] is set, middle lines matching it (case-insensitive)
|
||||
* are also retained in place — up to [salienceCap] of them — so a decisive error
|
||||
* buried in the middle of a long log survives truncation; remaining elided runs
|
||||
* collapse to a single "… N lines elided …" marker. Without [salience] the whole
|
||||
* middle collapses to one marker, as before.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("head_tail")
|
||||
data class HeadTail(val head: Int, val tail: Int) : CompressionRule
|
||||
data class HeadTail(
|
||||
val head: Int,
|
||||
val tail: Int,
|
||||
val salience: String? = null,
|
||||
val salienceCap: Int = 30,
|
||||
) : CompressionRule
|
||||
|
||||
/**
|
||||
* Collapse runs of identical adjacent lines into a single line, appending an
|
||||
|
||||
+24
@@ -44,6 +44,30 @@ class DeclarativeCompressorTest {
|
||||
assertEquals("line1\nline2\n… 6 lines elided …\nline9\nline10", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HeadTail with salience retains a matching middle line otherwise elided`() {
|
||||
val lines = (1..20).map { if (it == 10) "boom: ERROR occurred" else "line$it" }
|
||||
val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2, salience = "error"))), lines.joinToString("\n"))
|
||||
assertEquals(
|
||||
"line1\nline2\n… 7 lines elided …\nboom: ERROR occurred\n… 8 lines elided …\nline19\nline20",
|
||||
out,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HeadTail with salience caps the number of retained salient lines`() {
|
||||
val lines = (1..20).map { "error$it" }
|
||||
val out = compress(OutputCompressionSpec(listOf(HeadTail(0, 0, salience = "error", salienceCap = 3))), lines.joinToString("\n"))
|
||||
assertEquals("error1\nerror2\nerror3\n… 17 lines elided …", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HeadTail with salience preserves order and behaves like plain HeadTail when nothing matches`() {
|
||||
val raw = (1..10).joinToString("\n") { "line$it" }
|
||||
val out = compress(OutputCompressionSpec(listOf(HeadTail(2, 2, salience = "error"))), raw)
|
||||
assertEquals("line1\nline2\n… 6 lines elided …\nline9\nline10", out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HeadTail is a no-op at or under threshold`() {
|
||||
val raw = (1..4).joinToString("\n") { "line$it" }
|
||||
|
||||
@@ -27,6 +27,7 @@ CORREX kernel team.
|
||||
- `DefaultTransitionResolver` is stateless per call — it reads `EvaluationContext`, not persisted state.
|
||||
- Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip).
|
||||
- `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session.
|
||||
- `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists.
|
||||
- Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -52,7 +52,7 @@ data class StageConfig(
|
||||
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
|
||||
val semanticReview: Boolean = false,
|
||||
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's
|
||||
// terminal stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
|
||||
// write-declaring plan stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
|
||||
// file-writing stage as the generic `file_written` kind (never a typed code kind) and rarely sets
|
||||
// build_expectation, so a plan that scaffolds real code would otherwise never hit an execution gate.
|
||||
// When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Maintained alongside the features they describe. Any agent shipping a significan
|
||||
|
||||
- `architecture/` — stable, high-level architectural docs (context layers, event model, replay model, security boundaries).
|
||||
- `decisions/` — ADRs (adr-NNNN-*.md). Append-only. Never delete or retroactively alter a decided ADR; write a superseding one instead.
|
||||
- `qa/` — QA run plans (QA-*.md). Each maps to a shipped feature. `TEMPLATE.md` is the canonical shape. `ENV.md` describes the required live environment. `README.md` explains the QA process.
|
||||
- `qa/` — QA run plans (QA-*.md). Each maps to a shipped feature or an explicitly pending controlled audition. `TEMPLATE.md` is the canonical shape. `ENV.md` describes the required live environment. `README.md` explains the QA process.
|
||||
- `specs/` — feature specs by date-slug. Inputs for planned or in-flight work.
|
||||
- `schemas/` — canonical JSON schemas for structured outputs (analysis, brief_echo, design, execution_plan, impl_plan). Shared across the router and validator.
|
||||
- `epics/`, `modules/`, `diagrams/`, `design/`, `reviews/`, `visual/` — supporting reference material.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Stage prompt audition results — 2026-07-16
|
||||
|
||||
## Decision
|
||||
|
||||
Do not claim a material performance benefit for `CURATED_STAGE_OPERATING_GUIDANCE` from this audition. Keep or remove it on clarity and token-cost grounds, not on measured workflow efficiency.
|
||||
|
||||
Both variants completed and built successfully in all five controlled samples. Guidance ON had a slightly lower median inference count, but a slightly higher mean because of one severe failure loop. Pairwise outcomes and aggregate error counts do not show a stable causal advantage. The defensible finding is **no demonstrated benefit and no demonstrated final-outcome harm, with high run-to-run variance in both variants**.
|
||||
|
||||
The guidance block under test was:
|
||||
|
||||
```text
|
||||
## Operating guidance
|
||||
Verify the current workspace before declaring completion. Read before modifying existing files.
|
||||
When the authoritative intent plainly requires project setup, creating its manifest, configuration,
|
||||
or entry file is in scope; do not spend turns re-deciding that settled boundary. Re-observe facts
|
||||
instead of assuming them, and use the exact gate/tool feedback to make the smallest effective fix.
|
||||
```
|
||||
|
||||
Guidance OFF removed only this L0 system entry through `CORREX_STAGE_GUIDANCE=off`.
|
||||
|
||||
## Controlled batch
|
||||
|
||||
- Intent: scaffold a minimal Vite + React + TypeScript frontend in `frontend/` and make `npm run build` pass.
|
||||
- Scratch workspace: `/tmp/qa-audition-ws`, wiped before every sample.
|
||||
- Driver: `scripts/prompt-audition.sh` with `BATCH_RUNS=5`, variants `on off`, and `POLL_SECS=1200`.
|
||||
- Containment gate: every sample required matching `SessionWorkspaceBound.workspaceRoot` and `WorkspaceStateObserved.workspaceRoot`; any emitted `RepoMapComputed.repoRoot` also had to match.
|
||||
- Completion gate: only a `WorkflowCompleted` or `WorkflowFailed` after `ExecutionPlanLocked` counted.
|
||||
- Approvals: the unattended driver approved pending T2 gates every 15 seconds.
|
||||
- Build gate: after the workflow terminal, the driver independently ran `npm run build` in the generated frontend.
|
||||
|
||||
### Per-run results
|
||||
|
||||
| Pair | Variant | Session | Terminal | Build | Inferences | Approvals | Tools completed | Rejected | Failed | Retries |
|
||||
|---:|---|---|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | ON | `c9d4a30e-b71d-4137-a850-9b6428e30137` | completed | passed | 20 | 10 | 13 | 0 | 1 | 0 |
|
||||
| 1 | OFF | `c656e3ca-b8dd-43b4-97d9-74add7e05cdc` | completed | passed | 24 | 11 | 16 | 0 | 1 | 1 |
|
||||
| 2 | ON | `62929ec7-8216-4a53-a300-3fa61ff7e547` | completed | passed | 20 | 13 | 14 | 0 | 0 | 0 |
|
||||
| 2 | OFF | `1af1364b-9fcb-48b3-bbe8-90110f77f53d` | completed | passed | 21 | 13 | 14 | 0 | 0 | 1 |
|
||||
| 3 | ON | `310f7220-3157-4567-a495-7be217d2c1df` | completed | passed | 68 | 46 | 33 | 2 | 27 | 0 |
|
||||
| 3 | OFF | `10c35e0d-8c63-40a8-a948-b9272407ca5f` | completed | passed | 27 | 14 | 20 | 0 | 1 | 0 |
|
||||
| 4 | ON | `6e3f8809-93df-4e58-9ca5-7f05b3b0500a` | completed | passed | 18 | 12 | 12 | 0 | 0 | 0 |
|
||||
| 4 | OFF | `362bb82d-71dc-497f-ad73-870db5c33e72` | completed | passed | 21 | 14 | 14 | 0 | 1 | 0 |
|
||||
| 5 | ON | `84e69b2a-73f8-4052-a05a-00f7ab0f9349` | completed | passed | 44 | 22 | 33 | 0 | 3 | 1 |
|
||||
| 5 | OFF | `a160a33e-51a5-4e20-98c9-5a635a276fd5` | completed | passed | 67 | 32 | 49 | 2 | 8 | 0 |
|
||||
|
||||
### Aggregate results
|
||||
|
||||
| Metric | Guidance ON | Guidance OFF | Interpretation |
|
||||
|---|---:|---:|---|
|
||||
| Workflow success | 5/5 | 5/5 | no difference |
|
||||
| Independent build success | 5/5 | 5/5 | no difference |
|
||||
| Inferences, mean | 34.0 | 32.0 | OFF lower by 2; outlier-sensitive |
|
||||
| Inferences, median | 20 | 24 | ON lower by 4 |
|
||||
| Inferences, range | 18–68 | 21–67 | both have large tails |
|
||||
| Approvals, mean | 20.6 | 16.8 | OFF lower, dominated by ON run 3 |
|
||||
| Approvals, median | 13 | 14 | effectively tied |
|
||||
| Tools completed, mean | 21.0 | 22.6 | effectively tied |
|
||||
| Rejections, total | 2 | 2 | tied |
|
||||
| Failures, total | 31 | 11 | OFF lower; 27 ON failures came from run 3 |
|
||||
| Retries, total | 1 | 2 | ON lower by one |
|
||||
|
||||
ON used fewer inferences in four of five paired comparisons, but the single ON run-3 failure loop erased that advantage in the mean. OFF then produced its own long-tail result in run 5. With five samples per variant, the distribution is too small and heavy-tailed to attribute these differences to the guidance block.
|
||||
|
||||
## ON run 3 failure analysis
|
||||
|
||||
The error did not originate in the user intent, analyst artifact, operating guidance, or prior-plan hint. The architect inference generated a locked execution plan whose `scaffold_files` prompt included the nonexistent npm package:
|
||||
|
||||
```json
|
||||
"@types/vite": "^4.0.0"
|
||||
```
|
||||
|
||||
That generated prompt became authoritative downstream context, and `scaffold_files` wrote it into `frontend/package.json`. The structural plan and file gates accepted the manifest because it was valid JSON with the required keys; they do not resolve package names.
|
||||
|
||||
`npm install` returned an exact registry 404 for `@types/vite`. The model nevertheless retried npm repeatedly, tried explicit versions and flags, switched to Yarn, probed pnpm and network state, and hit two read-before-write rejections before eventually rewriting the manifest without the invalid package. Stage inference counts were:
|
||||
|
||||
| Stage | Inferences |
|
||||
|---|---:|
|
||||
| `scaffold_files` | 31 |
|
||||
| `install_dependencies` | 26 |
|
||||
| `verify_build` | 6 |
|
||||
| `analyst` | 2 |
|
||||
| `discovery` | 2 |
|
||||
| `architect` | 1 |
|
||||
|
||||
The guidance sentence about using exact feedback and making the smallest fix was directly applicable but did not stop the loop. This argues for deterministic defenses rather than stronger prose alone:
|
||||
|
||||
- Validate or resolve manifest dependencies before locking or accepting a generated scaffold.
|
||||
- Bound repeated equivalent failures within a stage.
|
||||
- Convert an unambiguous package 404 into structured recovery evidence that identifies the offending manifest entry.
|
||||
- Require a materially different action after repeated identical failures.
|
||||
|
||||
## Earlier samples
|
||||
|
||||
Earlier exploratory sessions suggested that ON might be cheaper than OFF, followed by an ON repeat that varied substantially. Those samples are excluded from the controlled aggregate because the later audit found a separate `[project].root` leak: tool writes were contained in scratch, but repo-map and project-memory context could still be sourced from the real Correx repository.
|
||||
|
||||
The server now rebinds project observation and memory to the authoritative boot workspace, and the batch driver checks the recorded project-observation root before allowing a sample to proceed. Earlier session IDs remain useful only as exploratory history:
|
||||
|
||||
- First apparent ON/OFF pair: `742e6330-f4ab-42a4-9466-4c8343452560`, `83ae4e27-ed4b-4d45-ad90-2db177487eaa`.
|
||||
- Additional ON repeat: `9721ba88-4819-401b-8d68-5d667ecdfe64`.
|
||||
- Discarded contaminated preflight sample: `8a1a5a3d-7177-4682-afc1-61e179aaa95c`.
|
||||
|
||||
Do not combine their metrics with the five-pair table.
|
||||
|
||||
## Artifacts and reproduction
|
||||
|
||||
Batch artifacts are local and gitignored under:
|
||||
|
||||
```text
|
||||
scripts/output/prompt-audition-20260716-165518/
|
||||
```
|
||||
|
||||
Important files:
|
||||
|
||||
- `results.tsv` — authoritative per-run metric table.
|
||||
- `batch.log` — unattended driver progress and final rendered table.
|
||||
- `server-NN-on.log`, `server-NN-off.log` — full server/inference/event logs for each sample.
|
||||
- `build-NN-on.log`, `build-NN-off.log` — independent post-terminal build output.
|
||||
- `pid` — detached batch PID used during the run; the process has exited.
|
||||
|
||||
Reproduce a five-pair detached batch with:
|
||||
|
||||
```bash
|
||||
DETACH=1 BATCH_RUNS=5 POLL_SECS=1200 WORKDIR=/tmp/qa-audition-ws \
|
||||
bash scripts/prompt-audition.sh
|
||||
```
|
||||
|
||||
The command prints the timestamped batch directory. A single pair remains the default when `BATCH_RUNS` is omitted. `VARIANTS=on` or `VARIANTS=off` runs only one side.
|
||||
|
||||
## Limitations
|
||||
|
||||
- No deterministic model seed was pinned, so the audition measures observed stochastic behavior rather than a reproducible seeded benchmark.
|
||||
- One task fixture cannot establish general guidance value across coding, research, and repair workflows.
|
||||
- Five samples per variant are enough to expose variance but not enough for a strong statistical claim about heavy-tailed behavior.
|
||||
- Cross-session “plan shape that worked before” memory remained part of normal runtime context for both variants; only the operating-guidance entry differed.
|
||||
- Metrics count event-log activity, not token usage or wall-clock duration per stage.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Treat the current guidance block as performance-neutral and non-protective. If its token cost is material, remove or shorten it unless it has a separate clarity or policy rationale. Prioritize deterministic plan/package validation and bounded recovery loops; those address the observed failure mechanism directly.
|
||||
|
||||
If further prompt evidence is desired, run a seeded matrix across multiple task fixtures and compare distributions, not single-run totals. Record token counts and stage durations in the batch summary before the next audition.
|
||||
@@ -0,0 +1,34 @@
|
||||
# QA Plan: Stage prompt audition
|
||||
|
||||
**Status:** COMPLETED — guidance benefit not demonstrated
|
||||
**Run date / operator:** 2026-07-16 — local 12B audition; unseeded stochastic batch
|
||||
**BACKLOG item:** Vikunja #169
|
||||
|
||||
**Results:** [QA-stage-prompt-audition-results-2026-07-16.md](QA-stage-prompt-audition-results-2026-07-16.md)
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [x] Use one fixed intent and a freshly wiped scratch workspace per variant.
|
||||
- [x] Retain per-session server logs, independent build logs, and a machine-readable result table.
|
||||
- [ ] Pin and record a deterministic seed and complete model checksum/config metadata in a future benchmark-grade rerun.
|
||||
|
||||
## Acceptance gate
|
||||
|
||||
> A curated prompt variant is adopted only if it improves gate pass rate or wasted tool rounds without increasing context truncation on the same model and task fixture.
|
||||
|
||||
## Checks
|
||||
|
||||
| # | Action | Expected observable evidence | Result |
|
||||
|---|---|---|---|
|
||||
| 1 | Run guidance ON and OFF variants for five samples each. | Session event logs identify every stage inference and terminal workflow outcome. | Pass: 10/10 completed |
|
||||
| 2 | Compare build success, inference turns, approvals, tool outcomes, and retries. | Per-variant result table derived from events against the same fixture. | Pass: results report committed |
|
||||
| 3 | Select the smallest improving variant. | A committed finding names the selected variant and evidence; otherwise no prompt expansion lands. | No winner: benefit not demonstrated |
|
||||
|
||||
## Current static audit finding
|
||||
|
||||
The runtime has two stage-facing sources: workflow prompt files and the compact `CURATED_STAGE_OPERATING_GUIDANCE` L0 system entry. The completed five-pair audition found identical final success and no stable efficiency improvement from guidance. See the linked results report for the controlled data, excluded exploratory sessions, and the architect-generated `@types/vite` failure loop.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A live benchmark result; this repository has no fixed model artifact or benchmark fixture checked in.
|
||||
- Replacing deterministic gates with prompt text.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Capability accretion
|
||||
|
||||
Make run N+1 better through deterministic, replay-safe facts mined from validated prior runs—not model training or mutable hidden memory.
|
||||
|
||||
Promote a validated failure-to-fix pair by a reusable class key: gate, evaluator/toolchain kind, and normalized failure fingerprint. Store a reference to the validated fix artifact/diff and the evidence that it passed.
|
||||
|
||||
1. Extend the concept rails to retain `classKey`, `fixRef`, confidence, and contradiction state.
|
||||
2. Deliver high-confidence facts deterministically at plan compilation (contract/gate tightening) or as a bounded stage instruction. L3 remains an instance-recall fallback.
|
||||
3. Mine positive patterns separately: successful plan shapes, resolved commands, and useful retrievals.
|
||||
4. Revoke or decay facts when later recorded evidence contradicts them.
|
||||
|
||||
Every promotion and delivery is event-derived and recorded; replay never re-mines live state.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Closed-loop workspace
|
||||
|
||||
## Decision
|
||||
|
||||
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.
|
||||
|
||||
## Known-good workspace invariant
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Stage prompt audit and audition
|
||||
|
||||
## Audit
|
||||
|
||||
Inventory each system-role/context block, its origin, token estimate, and outcome evidence. Remove redundant or stale text before adding rules. Inspect retry feedback separately: only the latest failure belongs in the prompt.
|
||||
|
||||
## Candidate additions
|
||||
|
||||
- Project build manifests, configs, and entry files are normal in-scope setup when required by the authoritative intent.
|
||||
- Resolve a necessary scope edge from the authoritative intent; do not spend turns re-deciding it.
|
||||
- Read/observe before writing and verify before declaring completion.
|
||||
|
||||
## Audition harness
|
||||
|
||||
Run a fixed task and seed through baseline, trimmed, curated-rules, and reworded-scope variants. Record completion, gate pass rate, rejected/wasted tool rounds, retries, token budget use, and context truncation. Choose the smallest variant that improves the 12B results; structural gates remain preferred where a deterministic mechanism exists.
|
||||
@@ -21,6 +21,7 @@ Each TOML file in `workflows/` is a valid workflow loadable by the server. Keep
|
||||
|
||||
- Add a new workflow example when a major new workflow type ships.
|
||||
- Prompts referenced by TOML files go in `workflows/prompts/`.
|
||||
- Freestyle architect prompts specify stage constraints, boundaries, and verification goals; they do not prescribe an exact resulting file list when the authoritative intent leaves implementation details open.
|
||||
- Do not add configs/plugins/stages stubs speculatively — populate when there is real content.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -81,6 +81,10 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
||||
decompose tasks here — task creation is out of scope for the plan.
|
||||
- Keep stages small and single-responsibility. Prefer more stages over large monolithic
|
||||
prompts.
|
||||
- Write stage prompts as constraints and verification goals, not a pre-scripted list of exact
|
||||
resulting files or their full contents. State the responsibility, boundaries, existing APIs or
|
||||
references to honor, acceptance checks, and what must not change; let the implementer inspect the
|
||||
actual workspace and choose the smallest fitting file edits.
|
||||
- **Declare `touches` to fence a stage to its area.** When a stage's job belongs to one part of
|
||||
the tree (a frontend/client stage → `["frontend/**"]`; a stage that edits only the API layer →
|
||||
`["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes`
|
||||
|
||||
@@ -20,7 +20,12 @@ start = "implement"
|
||||
id = "implement"
|
||||
prompt = "prompts/implement.md"
|
||||
produces = [{ name = "patch", kind = "file_written" }]
|
||||
allowed_tools = ["file_write", "task_create", "task_update", "task_context", "task_search"]
|
||||
allowed_tools = [
|
||||
"file_write", "task_create", "task_update", "task_context", "task_search",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
]
|
||||
token_budget = 8192
|
||||
max_retries = 3
|
||||
|
||||
@@ -31,7 +36,11 @@ id = "review"
|
||||
prompt = "prompts/review.md"
|
||||
needs = ["patch"]
|
||||
produces = [{ name = "review_report", kind = "review_report" }]
|
||||
allowed_tools = ["task_context", "task_update"]
|
||||
allowed_tools = [
|
||||
"task_context", "task_update",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
]
|
||||
token_budget = 8192
|
||||
max_retries = 3
|
||||
|
||||
|
||||
@@ -38,7 +38,15 @@ start = "discovery"
|
||||
id = "discovery"
|
||||
prompt = "prompts/discovery.md"
|
||||
produces = [{ name = "discovery", kind = "discovery" }]
|
||||
allowed_tools = ["file_read", "list_dir", "shell"]
|
||||
# Bootstraps the code-intelligence graph (index_repository) so downstream stages can query it,
|
||||
# plus grounds vetting against the real architecture. mcp__codebase-memory__* come from the
|
||||
# [[mcp]] side-car in config.toml.
|
||||
allowed_tools = [
|
||||
"file_read", "list_dir", "shell",
|
||||
"mcp__codebase-memory__index_repository",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__get_architecture",
|
||||
]
|
||||
token_budget = 16384
|
||||
max_retries = 2
|
||||
|
||||
@@ -49,7 +57,14 @@ max_retries = 2
|
||||
id = "analyst"
|
||||
prompt = "prompts/analyst.md"
|
||||
produces = [{ name = "analysis", kind = "analysis" }]
|
||||
allowed_tools = ["file_read", "shell", "task_search", "task_context", "task_create"]
|
||||
allowed_tools = [
|
||||
"file_read", "shell", "task_search", "task_context", "task_create",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__search_graph",
|
||||
"mcp__codebase-memory__get_architecture",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
]
|
||||
ground_references = true
|
||||
token_budget = 16384
|
||||
max_retries = 2
|
||||
@@ -60,6 +75,13 @@ id = "architect"
|
||||
prompt = "prompts/architect.md"
|
||||
needs = ["analysis"]
|
||||
produces = [{ name = "design", kind = "design" }]
|
||||
# Read-only code intelligence to decide component boundaries against the real call graph.
|
||||
# NOTE: this flips the architect from a pure-reasoning stage into a tool-calling one.
|
||||
allowed_tools = [
|
||||
"mcp__codebase-memory__get_architecture",
|
||||
"mcp__codebase-memory__search_graph",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
]
|
||||
token_budget = 16384
|
||||
max_retries = 2
|
||||
|
||||
@@ -71,7 +93,11 @@ max_retries = 2
|
||||
id = "decomposer"
|
||||
prompt = "prompts/task_planner.md"
|
||||
needs = ["design"]
|
||||
allowed_tools = ["file_read", "shell", "task_search", "task_context", "task_decompose", "task_create"]
|
||||
allowed_tools = [
|
||||
"file_read", "shell", "task_search", "task_context", "task_decompose", "task_create",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__search_graph",
|
||||
]
|
||||
require_task_decompose = true
|
||||
token_budget = 16384
|
||||
max_retries = 2
|
||||
@@ -88,6 +114,9 @@ claim_task = true
|
||||
allowed_tools = [
|
||||
"file_read", "file_write", "file_edit", "shell", "propose_scope",
|
||||
"task_create", "task_update", "task_context", "task_search",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
]
|
||||
token_budget = 32768
|
||||
max_retries = 3
|
||||
@@ -100,7 +129,12 @@ id = "reviewer"
|
||||
prompt = "prompts/reviewer.md"
|
||||
needs = ["patch", "analysis"]
|
||||
produces = [{ name = "review_report", kind = "review_report" }]
|
||||
allowed_tools = ["task_context", "task_update"]
|
||||
allowed_tools = [
|
||||
"task_context", "task_update",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
]
|
||||
token_budget = 32768
|
||||
max_retries = 2
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ start = "planner"
|
||||
[[stages]]
|
||||
id = "planner"
|
||||
prompt = "prompts/task_planner.md"
|
||||
allowed_tools = ["file_read", "shell", "task_search", "task_context", "task_decompose", "task_create"]
|
||||
allowed_tools = [
|
||||
"file_read", "shell", "task_search", "task_context", "task_decompose", "task_create",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__search_graph",
|
||||
]
|
||||
require_task_decompose = true
|
||||
token_budget = 16384
|
||||
max_retries = 2
|
||||
|
||||
@@ -15,6 +15,7 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
|
||||
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
|
||||
- Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
||||
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
|
||||
- Model descriptors default to a 24,576-token context window, matching the implementation-stage budget; operators may lower `[[models]].context_size` for constrained hardware.
|
||||
- `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@ data class ModelDescriptor(
|
||||
val modelPath: String,
|
||||
val residencyMode: ResidencyMode,
|
||||
val idleTimeoutMs: Long = 60_000L,
|
||||
val contextSize: Int = 8192,
|
||||
val contextSize: Int = 24_576,
|
||||
val capabilities: Set<CapabilityScore> = emptySet(),
|
||||
)
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||
- `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
+39
-2
@@ -127,6 +127,33 @@ class ListDirTool(
|
||||
}
|
||||
|
||||
private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult {
|
||||
var listedRoot = root
|
||||
var listing = collect(listedRoot, recursive)
|
||||
val descended = ArrayList<String>()
|
||||
// Do not follow symlinks: a symlink chain can point back to an ancestor outside the walk's
|
||||
// normal cycle protections. A real directory chain is capped as a second guard against a
|
||||
// pathological generated tree.
|
||||
while (!recursive && descended.size < MAX_AUTO_DESCENT_DEPTH) {
|
||||
val only = listing.entries.singleOrNull() ?: break
|
||||
val child = listedRoot.resolve(only.removeSuffix("/"))
|
||||
if (!only.endsWith('/') || Files.isSymbolicLink(child) || !Files.isDirectory(child)) break
|
||||
descended += only.removeSuffix("/")
|
||||
listedRoot = child
|
||||
listing = collect(listedRoot, recursive = false)
|
||||
}
|
||||
return ToolResult.Success(
|
||||
request.invocationId,
|
||||
output = render(listedRoot, listing.entries, listing.truncated, listing.ignoredCount, descended),
|
||||
)
|
||||
}
|
||||
|
||||
private data class Listing(
|
||||
val entries: List<String>,
|
||||
val truncated: Boolean,
|
||||
val ignoredCount: Int,
|
||||
)
|
||||
|
||||
private fun collect(root: Path, recursive: Boolean): Listing {
|
||||
val matcher = GitignoreMatcher(workingDir ?: root, root)
|
||||
// The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire
|
||||
// subtree gitignored as scaffold output), don't let that same rule keep pruning everything
|
||||
@@ -166,7 +193,7 @@ class ListDirTool(
|
||||
override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
|
||||
})
|
||||
out.sort()
|
||||
return ToolResult.Success(request.invocationId, output = render(root, out, truncated, ignoredCount))
|
||||
return Listing(out, truncated, ignoredCount)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,8 +203,17 @@ class ListDirTool(
|
||||
* gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
|
||||
* the real tree.
|
||||
*/
|
||||
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
|
||||
private fun render(
|
||||
root: Path,
|
||||
entries: List<String>,
|
||||
truncated: Boolean,
|
||||
ignoredCount: Int,
|
||||
descended: List<String>,
|
||||
): String {
|
||||
val notes = buildList {
|
||||
if (descended.isNotEmpty()) {
|
||||
add("auto-descended ${descended.joinToString(" → ")} (single child at each level); listing shown is $root")
|
||||
}
|
||||
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
|
||||
if (ignoredCount > 0) {
|
||||
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
|
||||
@@ -240,6 +276,7 @@ class ListDirTool(
|
||||
|
||||
private companion object {
|
||||
const val MAX_ENTRIES = 400
|
||||
const val MAX_AUTO_DESCENT_DEPTH = 32
|
||||
const val MAX_SIMILAR_SUGGESTIONS = 3
|
||||
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
|
||||
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
|
||||
|
||||
+19
-1
@@ -49,9 +49,10 @@ class ListDirToolTest {
|
||||
fun `default is shallow — a bare list_dir does not descend`(): Unit = runBlocking {
|
||||
// Regression: a recursive default buried top-level answers ("does frontend/ exist?") under a
|
||||
// 400-entry alphabetical flood and drove models to re-issue the same list_dir 2-3x. A bare
|
||||
// call (no recursive param) must now list only immediate children.
|
||||
// call (no recursive param) must not descend a directory that already has a meaningful choice.
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request()) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
@@ -75,9 +76,26 @@ class ListDirToolTest {
|
||||
fun `non-recursive lists only immediate children`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") }
|
||||
Files.writeString(root.resolve("README.md"), "root file")
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
assertTrue(out.contains("src/"), out)
|
||||
assertFalse(out.contains("deep"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-recursive listing auto-descends a single-child directory chain`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("listdir")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main"))
|
||||
Files.writeString(root.resolve("core/kernel/src/main/App.kt"), "class App")
|
||||
Files.createDirectories(root.resolve("core/kernel/src/main/resources"))
|
||||
|
||||
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
|
||||
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
|
||||
|
||||
assertTrue(out.contains("<path>${root.resolve("core/kernel/src/main")}</path>"), out)
|
||||
assertTrue(out.contains("App.kt"), out)
|
||||
assertTrue(out.contains("resources/"), out)
|
||||
assertTrue(out.contains("auto-descended core → kernel → src → main"), out)
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.tools.contract.Tool
|
||||
|
||||
/**
|
||||
* A running MCP server mounted into Correx: its advertised tools (ready to hand to the tool
|
||||
* registry as `extraTools`) plus the handle that shuts the child process down. The caller registers
|
||||
* [close] with the server's shutdown hook, exactly as the managed-model path unloads llama-server.
|
||||
*/
|
||||
class MountedMcpServer internal constructor(
|
||||
val serverId: String,
|
||||
private val client: McpClient,
|
||||
val tools: List<Tool>,
|
||||
) : AutoCloseable {
|
||||
override fun close() = client.close()
|
||||
}
|
||||
|
||||
/** Spawn an MCP server over stdio, initialize it, and surface its `tools/list` as Correx tools. */
|
||||
object McpMounter {
|
||||
suspend fun mount(
|
||||
serverId: String,
|
||||
command: List<String>,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
tier: Tier = Tier.T2,
|
||||
): MountedMcpServer {
|
||||
val client = McpClient.spawn(serverId, command, env)
|
||||
val tools = client.listTools().map { descriptor ->
|
||||
McpTool(
|
||||
serverId = serverId,
|
||||
remoteName = descriptor.name,
|
||||
description = descriptor.description,
|
||||
parametersSchema = descriptor.inputSchema,
|
||||
tier = tier,
|
||||
client = client,
|
||||
)
|
||||
}
|
||||
return MountedMcpServer(serverId, client, tools)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Minimal JSON-RPC 2.0 + MCP wire types — just the tools slice Correx needs to mount an MCP
|
||||
* server's tools as Correx tools. Resources, prompts, and sampling are intentionally absent (nothing
|
||||
* consumes them yet). `ignoreUnknownKeys` on the codec lets servers send richer objects than these.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class JsonRpcRequest(
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val id: Long,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcNotification(
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcResponse(
|
||||
val id: Long? = null,
|
||||
val result: JsonElement? = null,
|
||||
val error: JsonRpcError? = null,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: JsonElement? = null,
|
||||
)
|
||||
|
||||
/** One tool advertised by an MCP server (`tools/list` entry). */
|
||||
@Serializable
|
||||
internal data class McpToolDescriptor(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val inputSchema: JsonObject = JsonObject(emptyMap()),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class McpListToolsResult(val tools: List<McpToolDescriptor> = emptyList())
|
||||
|
||||
/** One content block of a `tools/call` result. Only text blocks are surfaced to the model. */
|
||||
@Serializable
|
||||
internal data class McpContentBlock(
|
||||
val type: String,
|
||||
val text: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class McpCallToolResult(
|
||||
val content: List<McpContentBlock> = emptyList(),
|
||||
@SerialName("isError") val isError: Boolean = false,
|
||||
) {
|
||||
/** Flatten text blocks into the single string the Correx tool contract returns. */
|
||||
fun text(): String = content.mapNotNull { it.text }.joinToString("\n")
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.tools.process.ChildProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.Writer
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* The transport an [McpClient] talks over. Extracted so tests can drive the client with an
|
||||
* in-process fake instead of spawning a real subprocess (keeps the client's protocol logic
|
||||
* deterministically testable — no process, no I/O).
|
||||
*/
|
||||
internal interface McpTransport {
|
||||
suspend fun request(request: JsonRpcRequest): JsonRpcResponse
|
||||
suspend fun notify(notification: JsonRpcNotification)
|
||||
fun close()
|
||||
}
|
||||
|
||||
private val MCP_JSON = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/**
|
||||
* A live MCP session: performs the `initialize` handshake, lists tools, and calls them. Ecosystem-
|
||||
* agnostic — it neither knows nor cares whether the server behind [transport] resolves npm packages,
|
||||
* indexes a codebase, or anything else. The nondeterminism of a `tools/call` is captured by the
|
||||
* kernel's normal tool-execution recording (the call rides the ToolExecutor path), so nothing here
|
||||
* needs to emit events: replay never re-calls because replay never executes tools.
|
||||
*/
|
||||
internal class McpClient(
|
||||
val serverId: String,
|
||||
private val transport: McpTransport,
|
||||
) : McpClientHandle {
|
||||
suspend fun initialize() {
|
||||
val params = buildJsonObject {
|
||||
put("protocolVersion", MCP_PROTOCOL_VERSION)
|
||||
putJsonObject("capabilities") {}
|
||||
putJsonObject("clientInfo") {
|
||||
put("name", "correx")
|
||||
put("version", "0")
|
||||
}
|
||||
}
|
||||
transport.request(JsonRpcRequest(method = "initialize", params = params, id = 0)).orThrow(serverId)
|
||||
transport.notify(JsonRpcNotification(method = "notifications/initialized"))
|
||||
}
|
||||
|
||||
suspend fun listTools(): List<McpToolDescriptor> {
|
||||
val result = transport.request(JsonRpcRequest(method = "tools/list", id = nextId())).orThrow(serverId)
|
||||
return MCP_JSON.decodeFromJsonElement(McpListToolsResult.serializer(), result).tools
|
||||
}
|
||||
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject): McpToolCallOutcome {
|
||||
val params = buildJsonObject {
|
||||
put("name", remoteName)
|
||||
put("arguments", arguments)
|
||||
}
|
||||
val result = transport.request(JsonRpcRequest(method = "tools/call", params = params, id = nextId()))
|
||||
.orThrow(serverId)
|
||||
val decoded = MCP_JSON.decodeFromJsonElement(McpCallToolResult.serializer(), result)
|
||||
return McpToolCallOutcome(text = decoded.text(), isError = decoded.isError)
|
||||
}
|
||||
|
||||
fun close() = transport.close()
|
||||
|
||||
private val ids = AtomicLong(1)
|
||||
private fun nextId() = ids.getAndIncrement()
|
||||
|
||||
private fun JsonRpcResponse.orThrow(serverId: String): JsonElement {
|
||||
error?.let { error("MCP server '$serverId' error ${it.code}: ${it.message}") }
|
||||
return result ?: error("MCP server '$serverId' returned no result")
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The protocol revision Correx advertises. Servers negotiate down if they only speak older.
|
||||
const val MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
/**
|
||||
* Spawn [command] as a child process and return an initialized client speaking MCP over its
|
||||
* stdio pipes. The caller owns [McpClient.close] (kills the process). Uses [ChildProcess] so
|
||||
* the server inherits the same unattended/non-interactive execution guarantees as every other
|
||||
* spawned child.
|
||||
*/
|
||||
suspend fun spawn(serverId: String, command: List<String>, env: Map<String, String>): McpClient {
|
||||
val process = withContext(Dispatchers.IO) {
|
||||
ChildProcess.builder(command)
|
||||
.apply {
|
||||
environment().putAll(env)
|
||||
// ChildProcess defaults stdin to /dev/null (so scaffolders fail fast instead of
|
||||
// hanging). MCP stdio is the opposite: the server READS requests from stdin, so
|
||||
// it needs a live pipe or it sees EOF and exits immediately. Restore the pipe.
|
||||
redirectInput(ProcessBuilder.Redirect.PIPE)
|
||||
}
|
||||
.start()
|
||||
}
|
||||
val client = McpClient(serverId, StdioTransport(process))
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Newline-delimited JSON-RPC over a child process's stdio (the MCP stdio transport). Requests are
|
||||
* serialized through a [Mutex]: one line written, then response lines drained until the matching id
|
||||
* arrives — server-initiated notifications/logs on the same pipe are skipped, not mistaken for the
|
||||
* reply. Blocking pipe I/O is confined to [Dispatchers.IO].
|
||||
*/
|
||||
private class StdioTransport(private val process: Process) : McpTransport {
|
||||
private val lock = Mutex()
|
||||
private val writer: Writer = process.outputStream.bufferedWriter()
|
||||
private val reader: BufferedReader = process.inputStream.bufferedReader()
|
||||
|
||||
override suspend fun request(request: JsonRpcRequest): JsonRpcResponse = lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLine(MCP_JSON.encodeToString(JsonRpcRequest.serializer(), request))
|
||||
while (true) {
|
||||
val line = reader.readLine()
|
||||
?: error("MCP server '${request.method}' closed the connection")
|
||||
if (line.isBlank()) continue
|
||||
val response = runCatching {
|
||||
MCP_JSON.decodeFromString(JsonRpcResponse.serializer(), line)
|
||||
}.getOrNull() ?: continue // not a response we can parse (a notification/log line) — skip
|
||||
if (response.id == request.id) return@withContext response
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun notify(notification: JsonRpcNotification) = lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLine(MCP_JSON.encodeToString(JsonRpcNotification.serializer(), notification))
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
process.destroy()
|
||||
}
|
||||
|
||||
private fun writeLine(json: String) {
|
||||
writer.write(json)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode a [ToolRequest]'s parameter map into the JsonObject `arguments` payload for a tools/call. */
|
||||
internal fun argumentsOf(parameters: Map<String, Any?>): JsonObject =
|
||||
JsonObject(parameters.mapValues { (_, value) -> anyToJsonElement(value) })
|
||||
|
||||
private fun anyToJsonElement(value: Any?): JsonElement = when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
is String -> JsonPrimitive(value)
|
||||
is Map<*, *> -> JsonObject(value.entries.associate { (k, v) -> k.toString() to anyToJsonElement(v) })
|
||||
is Iterable<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* One tool advertised by an MCP server, surfaced as a first-class Correx [Tool]/[ToolExecutor]. It
|
||||
* is exactly like any other tool from the kernel's point of view — so it inherits tier gating,
|
||||
* receipts, and event-recorded execution (#5) with zero special-casing, and replay reads the
|
||||
* recorded receipt instead of ever re-calling the server (#8/#9).
|
||||
*
|
||||
* Naming: `mcp__<server>__<remote>` — namespaced by server so two servers can advertise a tool of
|
||||
* the same name, and recognizable as MCP-backed at a glance (the same convention MCP hosts use).
|
||||
*
|
||||
* Capabilities are declared empty: an external MCP tool's side effects are opaque and cannot be
|
||||
* classified into Correx's FILE/NETWORK/SHELL taxonomy, so it does not claim any — safety comes from
|
||||
* [tier] (default T2 = approval-gated) rather than from a capability guess that would misfire the
|
||||
* plane-2 rules (e.g. NETWORK_ACCESS expects a NETWORK_TARGET param an MCP tool doesn't have).
|
||||
*/
|
||||
class McpTool(
|
||||
private val serverId: String,
|
||||
private val remoteName: String,
|
||||
override val description: String,
|
||||
override val parametersSchema: JsonObject,
|
||||
override val tier: Tier,
|
||||
private val client: McpClientHandle,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "mcp__${serverId}__$remoteName"
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult =
|
||||
runCatching { client.callTool(remoteName, argumentsOf(request.parameters)) }
|
||||
.fold(
|
||||
onSuccess = { result ->
|
||||
if (result.isError) {
|
||||
// A tool-reported failure (e.g. "package not found") — recoverable so it flows
|
||||
// back into the ReAct loop as evidence rather than aborting the stage.
|
||||
ToolResult.Failure(request.invocationId, result.text, recoverable = true)
|
||||
} else {
|
||||
ToolResult.Success(request.invocationId, result.text)
|
||||
}
|
||||
},
|
||||
// A transport/protocol failure (server crashed, bad frame) is not the model's fault
|
||||
// and not fixable by retrying the same call: non-recoverable.
|
||||
onFailure = { ToolResult.Failure(request.invocationId, "MCP call failed: ${it.message}", recoverable = false) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The slice of [McpClient] an [McpTool] needs — narrowed to an interface so tools are unit-testable. */
|
||||
interface McpClientHandle {
|
||||
suspend fun callTool(remoteName: String, arguments: JsonObject): McpToolCallOutcome
|
||||
}
|
||||
|
||||
/** Transport-agnostic result an [McpClientHandle] hands back (decouples the tool from wire types). */
|
||||
data class McpToolCallOutcome(val text: String, val isError: Boolean)
|
||||
+8
-1
@@ -62,7 +62,11 @@ class ShellTool(
|
||||
// head/tail window holds distinct content rather than duplicates. Dedup is lossless
|
||||
// (count is preserved) and order-preserving, so it is safe as a default — unlike a
|
||||
// progress-dropping regex, which can silently eat real output.
|
||||
listOf(StripBlankLines, CollapseConsecutiveDuplicates, HeadTail(HEAD_LINES, TAIL_LINES)),
|
||||
listOf(
|
||||
StripBlankLines,
|
||||
CollapseConsecutiveDuplicates,
|
||||
HeadTail(HEAD_LINES, TAIL_LINES, salience = SALIENCE_PATTERN),
|
||||
),
|
||||
bypassOnError = true,
|
||||
),
|
||||
)
|
||||
@@ -213,6 +217,9 @@ class ShellTool(
|
||||
private companion object {
|
||||
const val HEAD_LINES = 40
|
||||
const val TAIL_LINES = 40
|
||||
// Case-insensitive: a decisive error line buried in the middle of a long build/test log
|
||||
// (line 250 of 500) survives the head/tail window instead of being silently dropped.
|
||||
const val SALIENCE_PATTERN = "error|fail|exception|panic|traceback|✗"
|
||||
const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings."
|
||||
val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
|
||||
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
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.tools.contract.ToolResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Drives [McpClient] through an in-process [McpTransport] — no subprocess, no I/O. Verifies the
|
||||
* protocol logic (handshake, tools/list decode, tools/call outcome, error surfacing) and the
|
||||
* [McpTool] adapter's mapping onto the Correx tool contract, all deterministically.
|
||||
*/
|
||||
class McpClientTest {
|
||||
|
||||
/** A scripted transport: answers each method from a canned map; records notifications. */
|
||||
private class FakeTransport(private val results: Map<String, JsonRpcResponse>) : McpTransport {
|
||||
val notified = mutableListOf<String>()
|
||||
override suspend fun request(request: JsonRpcRequest): JsonRpcResponse =
|
||||
(results[request.method] ?: error("no scripted result for ${request.method}"))
|
||||
.copy(id = request.id)
|
||||
override suspend fun notify(notification: JsonRpcNotification) { notified.add(notification.method) }
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
private fun ok(json: kotlinx.serialization.json.JsonElement) = JsonRpcResponse(result = json)
|
||||
|
||||
@Test
|
||||
fun `initialize handshakes then lists and calls tools`(): Unit = runBlocking {
|
||||
val listResult = buildJsonObject {
|
||||
put("tools", kotlinx.serialization.json.buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("name", "search_code")
|
||||
put("description", "search the graph")
|
||||
})
|
||||
})
|
||||
}
|
||||
val callResult = buildJsonObject {
|
||||
put("content", kotlinx.serialization.json.buildJsonArray {
|
||||
add(buildJsonObject { put("type", "text"); put("text", "hit one") })
|
||||
add(buildJsonObject { put("type", "text"); put("text", "hit two") })
|
||||
})
|
||||
}
|
||||
val transport = FakeTransport(
|
||||
mapOf(
|
||||
"initialize" to ok(buildJsonObject {}),
|
||||
"tools/list" to ok(listResult),
|
||||
"tools/call" to ok(callResult),
|
||||
),
|
||||
)
|
||||
val client = McpClient("cm", transport)
|
||||
client.initialize()
|
||||
assertEquals(listOf("notifications/initialized"), transport.notified)
|
||||
|
||||
val tools = client.listTools()
|
||||
assertEquals(listOf("search_code"), tools.map { it.name })
|
||||
|
||||
val outcome = client.callTool("search_code", JsonObject(emptyMap()))
|
||||
assertEquals("hit one\nhit two", outcome.text)
|
||||
assertTrue(!outcome.isError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server error is surfaced as a thrown failure`(): Unit = runBlocking {
|
||||
val transport = FakeTransport(
|
||||
mapOf("tools/call" to JsonRpcResponse(error = JsonRpcError(code = -32000, message = "boom"))),
|
||||
)
|
||||
val client = McpClient("cm", transport)
|
||||
val ex = runCatching { client.callTool("x", JsonObject(emptyMap())) }.exceptionOrNull()
|
||||
assertTrue(ex?.message?.contains("boom") == true, "expected server error message, got: ${ex?.message}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `McpTool maps an isError result to a recoverable failure`(): Unit = runBlocking {
|
||||
val handle = object : McpClientHandle {
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject) =
|
||||
McpToolCallOutcome(text = "package not found", isError = true)
|
||||
}
|
||||
val tool = McpTool("cm", "resolve", "", JsonObject(emptyMap()), Tier.T2, handle)
|
||||
assertEquals("mcp__cm__resolve", tool.name)
|
||||
val result = tool.execute(req(tool.name)) as ToolResult.Failure
|
||||
assertEquals("package not found", result.reason)
|
||||
assertTrue(result.recoverable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `McpTool maps a normal result to success`(): Unit = runBlocking {
|
||||
val handle = object : McpClientHandle {
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject) =
|
||||
McpToolCallOutcome(text = "ok", isError = false)
|
||||
}
|
||||
val tool = McpTool("cm", "resolve", "", JsonObject(emptyMap()), Tier.T2, handle)
|
||||
val result = tool.execute(req(tool.name)) as ToolResult.Success
|
||||
assertEquals("ok", result.output)
|
||||
}
|
||||
|
||||
private fun req(name: String) = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("st-1"),
|
||||
toolName = name,
|
||||
parameters = mapOf("q" to "vite"),
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ Adapter for `core:transitions` and `core:inference` workflow interfaces. Depends
|
||||
- `ExecutionPlanCompiler` converts the parsed `ExecutionPlanModel` into a runnable plan for `core:transitions`; compilation is deterministic given the same input model.
|
||||
- `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
|
||||
- `PlanDerivedManifest` exposes the write manifest derived from a plan.
|
||||
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
+7
-4
@@ -55,7 +55,7 @@ private const val RECOVERY_PROMPT =
|
||||
// round, evicting the file_read results the model just gathered, so it re-reads forever and never
|
||||
// accumulates enough to write. Match the static execution baseline. (gemma ctx is 32768, so this
|
||||
// leaves ample headroom for completion.)
|
||||
private const val DEFAULT_STAGE_TOKEN_BUDGET = 16384
|
||||
private const val DEFAULT_STAGE_TOKEN_BUDGET = 24576
|
||||
|
||||
// A compiled freestyle stage must also lift its inference completion cap off StageConfig's 2048
|
||||
// default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local
|
||||
@@ -114,8 +114,11 @@ class ExecutionPlanCompiler(
|
||||
// ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) from the real
|
||||
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
|
||||
// paths do — so a docs-only plan is left alone and a code plan is always gated.
|
||||
val autoGateStageId: String? =
|
||||
if (declaredExpectations.values.all { it == BuildExpectation.NONE }) terminalStageId(plan) else null
|
||||
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
|
||||
plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||
@@ -149,7 +152,7 @@ class ExecutionPlanCompiler(
|
||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||
touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() },
|
||||
buildExpectation = declaredExpectations.getValue(s.id),
|
||||
autoBuildGate = s.id == autoGateStageId,
|
||||
autoBuildGate = s.id in autoGateStages,
|
||||
semanticReview = s.semanticReview,
|
||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||
generationConfig = defaultStageGeneration,
|
||||
|
||||
+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",
|
||||
)
|
||||
}
|
||||
+12
-12
@@ -470,9 +470,9 @@ class ExecutionPlanCompilerTest {
|
||||
"goal": "scaffold a react app",
|
||||
"stages": [
|
||||
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
|
||||
"needs": [], "tools": ["file_write"] },
|
||||
"needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"] },
|
||||
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
|
||||
"needs": ["app_entry"], "tools": ["file_write"] }
|
||||
"needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
|
||||
@@ -482,12 +482,12 @@ class ExecutionPlanCompilerTest {
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `terminal stage of an ungated plan is flagged for an auto build gate`() {
|
||||
fun `every write-declaring stage of an ungated plan is flagged for an auto build gate`() {
|
||||
val graph = codeCompiler.compile(codePlanNoGate, "wf")
|
||||
val views = graph.stages[StageId("views")]!!
|
||||
assertTrue(
|
||||
views.autoBuildGate,
|
||||
"terminal stage of an ungated plan must be flagged for an auto build gate",
|
||||
"a write-declaring stage of an ungated plan must be flagged for an auto build gate",
|
||||
)
|
||||
assertEquals(
|
||||
com.correx.core.transitions.graph.BuildExpectation.NONE,
|
||||
@@ -496,8 +496,8 @@ class ExecutionPlanCompilerTest {
|
||||
"code module was actually written",
|
||||
)
|
||||
assertTrue(
|
||||
!graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||
"intermediate stages are not flagged — the gate runs when the project is whole, not per-stage",
|
||||
graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||
"implementation stages must not defer compiler coverage to a terminal verifier",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -531,15 +531,15 @@ class ExecutionPlanCompilerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the terminal stage of an ungated plan is auto-flagged`() {
|
||||
// The compiler flags the terminal stage regardless of the produced kinds — code-ness is a
|
||||
// run-time decision (SessionOrchestrator reads the FileWritten manifest), so a non-code plan
|
||||
// is flagged too but the runtime build gate then skips it.
|
||||
fun `plan without declared writes has no auto build gate`() {
|
||||
// A plan with no write declarations cannot promise compiler coverage. Runtime still
|
||||
// inspects actual files for an opted-in gate, but the compiler must not pretend this
|
||||
// documentation-shaped plan has implementation-stage verification.
|
||||
val graph = compiler.compile(validPlan, "wf")
|
||||
assertEquals(
|
||||
1,
|
||||
0,
|
||||
graph.stages.values.count { it.autoBuildGate },
|
||||
"exactly the single terminal stage of an ungated plan is flagged for the auto build gate",
|
||||
"only write-declaring stages receive an auto build gate",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -115,12 +115,21 @@ class ReviewLoopWorkflowTest {
|
||||
|
||||
// implement claims/submits and may create a task, alongside its file write.
|
||||
assertEquals(
|
||||
setOf("file_write", "task_create", "task_update", "task_context", "task_search"),
|
||||
setOf(
|
||||
"file_write", "task_create", "task_update", "task_context", "task_search",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
),
|
||||
graph.stages[StageId("implement")]!!.allowedTools,
|
||||
)
|
||||
// review reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
setOf(
|
||||
"task_context", "task_update",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
),
|
||||
graph.stages[StageId("review")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-1
@@ -114,7 +114,12 @@ class RolePipelineWorkflowTest {
|
||||
assertTrue(implTools.containsAll(setOf("file_write", "file_edit", "task_update")))
|
||||
// reviewer reads the task and completes it on an approved verdict.
|
||||
assertEquals(
|
||||
setOf("task_context", "task_update"),
|
||||
setOf(
|
||||
"task_context", "task_update",
|
||||
"mcp__codebase-memory__search_code",
|
||||
"mcp__codebase-memory__trace_path",
|
||||
"mcp__codebase-memory__get_code_snippet",
|
||||
),
|
||||
graph.stages[StageId("reviewer")]!!.allowedTools,
|
||||
)
|
||||
}
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Inventory the stage-facing prompt surfaces before changing prompt policy. This is intentionally
|
||||
# static: it is safe to run without a model, server, event store, or workspace mutation.
|
||||
set -eu
|
||||
|
||||
root=${1:-.}
|
||||
cd "$root"
|
||||
|
||||
printf '%s\n\n' '# Stage prompt inventory'
|
||||
printf '%s\n' '| Surface | Source | Lines | Approx. tokens |'
|
||||
printf '%s\n' '|---|---|---:|---:|'
|
||||
|
||||
find examples/workflows/prompts -type f -name '*.md' -print 2>/dev/null | sort | while IFS= read -r file; do
|
||||
lines=$(wc -l < "$file" | tr -d ' ')
|
||||
chars=$(wc -c < "$file" | tr -d ' ')
|
||||
printf '| workflow prompt | `%s` | %s | %s |\n' "$file" "$lines" "$((chars / 4))"
|
||||
done
|
||||
|
||||
printf '| curated system guidance | `%s` | %s | %s |\n' \
|
||||
'core/kernel/.../DefaultSessionOrchestrator.kt' \
|
||||
"$(awk '/CURATED_STAGE_OPERATING_GUIDANCE =/{on=1} on{n++} /"""$/{if(on){print n; exit}}' core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt)" \
|
||||
"$(awk '/CURATED_STAGE_OPERATING_GUIDANCE =/{on=1} on{c+=length($0)+1} /"""$/{if(on){print int(c/4); exit}}' core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt)"
|
||||
|
||||
printf '\n## Candidates for review\n\n'
|
||||
rg -n -i '(^|[^[:alnum:]_])(must|always|never|required|exactly)([^[:alnum:]_]|$)' \
|
||||
examples/workflows/prompts core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt \
|
||||
2>/dev/null || true
|
||||
@@ -398,6 +398,27 @@ class RecoveryRoutingTest {
|
||||
start = StageId("impl"),
|
||||
)
|
||||
|
||||
// impl owns file_write but repeatedly produces the same static-analysis failure. Capability
|
||||
// possession alone is not progress: after the per-gate retry budget is spent, this must still
|
||||
// reach the recovery/intent-holder stage instead of terminating impl in place.
|
||||
private fun capableButStuckGraph(): WorkflowGraph = WorkflowGraph(
|
||||
id = "capable-but-stuck-test",
|
||||
stages = mapOf(
|
||||
StageId("impl") to StageConfig(
|
||||
allowedTools = setOf("file_write"),
|
||||
staticAnalysis = listOf("typecheck"),
|
||||
),
|
||||
StageId("recovery") to StageConfig(
|
||||
allowedTools = setOf("shell", "file_write"),
|
||||
metadata = mapOf("role" to "recovery"),
|
||||
),
|
||||
),
|
||||
transitions = setOf(
|
||||
TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("done"), condition = { true }),
|
||||
),
|
||||
start = StageId("impl"),
|
||||
)
|
||||
|
||||
/** Builds an orchestrator whose only writing stage is `impl` (via file_write) and whose `verify`
|
||||
* static gate fails with [staticOutput] — so route-to-owner can resolve `impl` as the file author. */
|
||||
private fun buildMultiToolOrchestrator(
|
||||
@@ -520,6 +541,33 @@ class RecoveryRoutingTest {
|
||||
assertTrue(tickets.take(firstEscalatedIdx).none { it.escalated }, "owner routes come before escalation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capable stage with unchanged failure exhausts to the recovery arbiter`(): Unit = runBlocking {
|
||||
val (orchestrator, eventStore) = buildMultiToolOrchestrator(
|
||||
staticOutput = "App.tsx: TS2322 type mismatch",
|
||||
)
|
||||
val sessionId = SessionId("capable-but-stuck")
|
||||
val config = OrchestrationConfig(
|
||||
retryPolicy = RetryPolicy(
|
||||
maxAttempts = 10,
|
||||
backoffMs = 0,
|
||||
perGateMaxAttempts = mapOf("static_analysis" to 1),
|
||||
),
|
||||
)
|
||||
|
||||
val result = orchestrator.run(sessionId, capableButStuckGraph(), config)
|
||||
|
||||
assertTrue(result is WorkflowResult.Failed, "the bounded repair ladder must still terminate")
|
||||
val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
assertEquals(RECOVERY_ROUTE_BUDGET_TEST, tickets.size)
|
||||
tickets.forEach {
|
||||
assertEquals("impl", it.stageId.value)
|
||||
assertEquals("recovery", it.routeTo.value)
|
||||
assertEquals("static_analysis", it.gate)
|
||||
assertTrue(it.escalated, "a self-owned failure must escalate directly to the intent-holder")
|
||||
}
|
||||
}
|
||||
|
||||
// impl carries a generative "scaffold" mandate as its own prompt. On the FORWARD visit it applies;
|
||||
// when the ticket routes impl back to repair its own file, that mandate must be suppressed (else it
|
||||
// re-scaffolds and stubs the real file). Same topology as ownerGraph otherwise.
|
||||
|
||||
Reference in New Issue
Block a user