Implement closed-loop workspace follow-ups

This commit is contained in:
2026-07-15 23:48:12 +04:00
parent 3a48ecd24f
commit ed7efb6072
51 changed files with 702 additions and 54 deletions
+2
View File
@@ -19,6 +19,8 @@ All sources under `apps/server/src/`.
- `GET /health` — health report (probes: event-store, llama-server, disk watermark) - `GET /health` — health report (probes: event-store, llama-server, disk watermark)
- `GET /stats` — metrics report (MetricsProjection) - `GET /stats` — metrics report (MetricsProjection)
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing - `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`) ### 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. - **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.
@@ -654,6 +654,11 @@ fun main() {
taskArtifactResolver = taskArtifactResolver, taskArtifactResolver = taskArtifactResolver,
taskSessionResolver = taskSessionResolver, taskSessionResolver = taskSessionResolver,
gitCommitReader = gitCommitReader, 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 // 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. // services. Built after the module so the rebuild hook can swap them in place.
@@ -133,6 +133,9 @@ class ServerModule(
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read // 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). // (the endpoint then only acts on commits supplied in the request body).
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null, 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( val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator, orchestrator = orchestrator,
@@ -409,17 +412,25 @@ class ServerModule(
bindProjectProfile(sessionId) bindProjectProfile(sessionId)
bindAgentInstructions(sessionId) bindAgentInstructions(sessionId)
runCatching { runCatching {
val result = orchestrator.run(sessionId, graph, sessionConfig) suspend fun runAndFinalize() {
freestyleHandoff(sessionId, graph, result) val result = orchestrator.run(sessionId, graph, sessionConfig)
// Distil this run's decisions into durable project memory on completion. freestyleHandoff(sessionId, graph, result)
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) } // Distil this run's decisions into durable project memory on completion.
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied). projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
operatorProfile?.let { profile -> // Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
profileAdaptationService?.let { svc -> operatorProfile?.let { profile ->
runCatching { svc.proposeAdaptation(sessionId, profile) } profileAdaptationService?.let { svc ->
.onFailure { log.warn("Profile adaptation failed: {}", it.message) } 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) } }.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) }
activeSessionJobs.remove(sessionId) activeSessionJobs.remove(sessionId)
} }
@@ -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)
}
@@ -19,7 +19,7 @@ import com.correx.core.talkie.l3.L3Query
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is * [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults * the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by * 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. * sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
*/ */
class ArchitectContradictionChecker( class ArchitectContradictionChecker(
@@ -35,8 +35,9 @@ class L3RepoKnowledgeRetriever(
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> { override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
val vector = embedder.embed(query) val vector = embedder.embed(query)
val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
// Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision). // Versioned trailing delimiter keeps sibling repo roots isolated and stale semantic
.filter { it.entry.turnId.startsWith("repomap:$repoRoot:") } // 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 } val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE }
if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() }) if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() })
return kept.take(k).map { it.toHit() } return kept.take(k).map { it.toHit() }
@@ -110,13 +110,13 @@ class ProjectMemoryService(
stateKey: String?, stateKey: String?,
entries: List<RepoMapEntry>, 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) log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey)
return return
} }
// Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling // Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
// (/repo vs /repo2) under the retriever's startsWith filter. // (/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 // Docs are already surfaced via the "Docs available" catalog built straight from
// RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding // RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
// them into the same L3 namespace as code let generic textual similarity (e.g. "analyst", // 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). // rather than the repo-map floor that was fixed then).
entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry -> entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
runCatching { 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( l3MemoryStore.store(
L3MemoryEntry( L3MemoryEntry(
id = UUID.randomUUID().toString(), 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 * Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
* nondeterministic environment observation) — the caller records the result as a * nondeterministic environment observation) — the caller records the result as a
* [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and * [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 * 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 * ~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(), path = path.relativeTo(repoRoot).toString(),
score = (mtimes.getValue(path) - min).toDouble() / span, score = (mtimes.getValue(path) - min).toDouble() / span,
symbols = extractSymbols(path), symbols = extractSymbols(path),
descriptor = sourceDescriptor(path),
) )
} }
.sortedByDescending { it.score } .sortedByDescending { it.score }
@@ -104,6 +106,35 @@ class RepoMapIndexer(
} ?: emptyList() } ?: 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 * 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. * `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line.
@@ -133,13 +164,21 @@ class RepoMapIndexer(
private fun String.truncateDescriptor(): String = private fun String.truncateDescriptor(): String =
if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + "" 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 { companion object {
/** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */ /** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys
private const val MAX_SYMBOLS_PER_FILE = 40 private const val MAX_SYMBOLS_PER_FILE = 40
private const val MAX_DESCRIPTOR_CHARS = 120 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 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. // Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf( val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
@@ -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
}
}
@@ -87,7 +87,7 @@ class ArchitectContradictionCheckerTest {
val store = CannedL3MemoryStore( val store = CannedL3MemoryStore(
listOf( listOf(
// Above threshold but a repo-map entry, not a decision — must be excluded. // 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) val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
@@ -19,8 +19,8 @@ class L3RepoKnowledgeRetrieverTest {
@Test @Test
fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking { fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore() val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", 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:/repo2:git:h", "repo2/B.kt: Bar", 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") val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10) .retrieve(SessionId("s"), "anything", 10)
@@ -31,8 +31,8 @@ class L3RepoKnowledgeRetrieverTest {
@Test @Test
fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking { fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore() val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.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:/repo:", "repo/B.kt", vec, 0L)) l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo:", "repo/B.kt", vec, 0L))
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo") val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10) .retrieve(SessionId("s"), "anything", 10)
@@ -118,7 +118,7 @@ class ProjectMemoryServiceReuseTest {
svc.indexAndRecord(sessionId, "/repo") svc.indexAndRecord(sessionId, "/repo")
assertTrue( assertTrue(
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"), l3.existsByTurnIdPrefix("repomap:v2:/repo:git:hash1"),
"entries should be embedded with stateKey tag", "entries should be embedded with stateKey tag",
) )
} }
@@ -131,7 +131,7 @@ class ProjectMemoryServiceReuseTest {
val embedder = ConstantEmbedderReuse() val embedder = ConstantEmbedderReuse()
// Index /repo and /repo2 into the same shared L3 store with the same stateKey. // 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". // 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 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"))) 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") .indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root. // existsByTurnIdPrefix with the delimiter must not match the other root.
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist") assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 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. // The retriever scoped to /repo must return only /repo's entry, never /repo2's.
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo") 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")) 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 @Test
fun `extracts GDScript top-level symbols`(@TempDir root: Path) { fun `extracts GDScript top-level symbols`(@TempDir root: Path) {
root.resolve("player.gd").writeText( root.resolve("player.gd").writeText(
+2
View File
@@ -23,6 +23,8 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
- Config is read from disk; it is not event-sourced. Do not add event/state/reducer structures here. - 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`. - `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. - `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 ## Verification
@@ -199,7 +199,7 @@ object ConfigLoader {
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6 private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
private const val DEFAULT_RETRIEVAL_K = 5 private const val DEFAULT_RETRIEVAL_K = 5
private const val DEFAULT_TOKEN_BUDGET = 4096 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_MEMORY_K = 5
private const val DEFAULT_PROJECT_MAX_DEPTH = 4 private const val DEFAULT_PROJECT_MAX_DEPTH = 4
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30 private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
@@ -735,6 +735,14 @@ object ConfigLoader {
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) }, repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
) )
val gitSection = sections["git"] ?: emptyMap()
val git = GitConfig(
enabled = asBoolean(gitSection["enabled"], false),
remote = asString(gitSection["remote"], "origin"),
baseBranch = asString(gitSection["base_branch"], "main"),
author = asString(gitSection["author"], ""),
)
return CorrexConfig( return CorrexConfig(
server = server, server = server,
tui = tui, tui = tui,
@@ -749,6 +757,7 @@ object ConfigLoader {
personalization = personalization, personalization = personalization,
orchestration = orchestration, orchestration = orchestration,
sampling = sampling, sampling = sampling,
git = git,
) )
} }
@@ -18,6 +18,21 @@ data class CorrexConfig(
val orchestration: OrchestrationKnobs = OrchestrationKnobs(), val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
val sampling: SamplingConfig = SamplingConfig(), val sampling: SamplingConfig = SamplingConfig(),
val health: HealthConfig = HealthConfig(), val health: HealthConfig = HealthConfig(),
val git: GitConfig = GitConfig(),
)
/**
* Optional transport for a server-owned checkout. Each run executes on a `run/<sessionId>` branch
* based on [baseBranch] and pushes that branch on terminal completion; the working directory stays
* a real local path, never a remote URL or mounted client filesystem.
*/
@Serializable
data class GitConfig(
val enabled: Boolean = false,
val remote: String = "origin",
val baseBranch: String = "main",
/** Optional Git author value, for example `Correx <correx@example.invalid>`. */
val author: String = "",
) )
/** /**
@@ -283,7 +298,8 @@ data class L3Config(
data class ModelConfig( data class ModelConfig(
val id: String, val id: String,
val modelPath: 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 params: Map<String, String> = emptyMap(),
val capabilities: Map<String, Double> = emptyMap(), val capabilities: Map<String, Double> = emptyMap(),
) )
@@ -109,6 +109,12 @@ object CorrexConfigWriter {
cfg.sampling.minP?.let { b.kv("min_p", it) } cfg.sampling.minP?.let { b.kv("min_p", it) }
cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", 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.section("personalization")
b.kv("enabled", cfg.personalization.enabled) b.kv("enabled", cfg.personalization.enabled)
b.kv("learn", cfg.personalization.learn) b.kv("learn", cfg.personalization.learn)
@@ -434,7 +434,7 @@ class ConfigLoaderTest {
assertEquals(1, result.models.size) assertEquals(1, result.models.size)
assertEquals("local-model", result.models[0].id) assertEquals("local-model", result.models[0].id)
assertEquals("/models/local.gguf", result.models[0].modelPath) assertEquals("/models/local.gguf", result.models[0].modelPath)
assertEquals(8192, result.models[0].contextSize) assertEquals(24_576, result.models[0].contextSize)
} }
@Test @Test
@@ -37,6 +37,7 @@ class CorrexConfigWriterTest {
), ),
personalization = PersonalizationConfig(enabled = true, learn = true), personalization = PersonalizationConfig(enabled = true, learn = true),
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40), 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), modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000), orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
providers = listOf( providers = listOf(
+2
View File
@@ -27,6 +27,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
- **SILENT FAILURE TRAP**: After adding any `EventPayload` subclass, immediately add it to `Serialization.kt` `eventModule` block. Run `./gradlew check` to verify. Tests may pass without it but runtime replay will fail silently. - **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. - `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. - 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. - 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. - `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection.
@@ -16,6 +16,8 @@ data class ContractAssertionResult(
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */ /** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
val evaluator: String, val evaluator: String,
val passed: Boolean, 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. */ /** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
val evidence: String, val evidence: String,
) )
@@ -36,6 +36,19 @@ data class WorkflowFailedEvent(
val retryExhausted: Boolean, val retryExhausted: Boolean,
) : EventPayload ) : 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 * 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 * (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace
@@ -11,6 +11,8 @@ data class RepoMapEntry(
val path: String, val path: String,
val score: Double, val score: Double,
val symbols: List<String> = emptyList(), val symbols: List<String> = emptyList(),
/** Bounded deterministic source-purpose text used for semantic repo retrieval. */
val descriptor: String = "",
) )
/** /**
@@ -89,6 +89,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent 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.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent import com.correx.core.events.events.TaskReleasedEvent
@@ -151,6 +152,7 @@ val eventModule = SerializersModule {
subclass(OrchestrationResumedEvent::class) subclass(OrchestrationResumedEvent::class)
subclass(OrchestrationPausedEvent::class) subclass(OrchestrationPausedEvent::class)
subclass(WorkflowStartedEvent::class) subclass(WorkflowStartedEvent::class)
subclass(RunBranchPushedEvent::class)
subclass(WorkflowFailedEvent::class) subclass(WorkflowFailedEvent::class)
subclass(WorkflowCompletedEvent::class) subclass(WorkflowCompletedEvent::class)
subclass(RetryAttemptedEvent::class) subclass(RetryAttemptedEvent::class)
+3
View File
@@ -20,6 +20,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session. - `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events. - `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions. - `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`. - `JournalCompactionService` — triggers journal compaction and emits `JournalCompactedEvent`.
- `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring. - `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring.
- `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning. - `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning.
@@ -69,8 +69,15 @@ internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
"execution" to "file_write", "execution" to "file_write",
"contract" to "file_write", "contract" to "file_write",
"static_analysis" to "file_write", "static_analysis" to "file_write",
"workspace_precondition" to "file_write",
) )
internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance
Verify the current workspace before declaring completion. Read before modifying existing files.
When the authoritative intent plainly requires project setup, creating its manifest, configuration,
or entry file is in scope; do not spend turns re-deciding that settled boundary. Re-observe facts
instead of assuming them, and use the exact gate/tool feedback to make the smallest effective fix."""
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path). // Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
internal fun ticketCategory(gate: String): String = when (gate) { internal fun ticketCategory(gate: String): String = when (gate) {
"plan_compile" -> "planning" "plan_compile" -> "planning"
@@ -44,8 +44,9 @@ internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET]. * capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
* *
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal), * 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 * or null to fall through to the normal per-gate retry path. A stage that already holds the
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists. * 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( internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
@@ -56,7 +57,7 @@ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
): StepResult? { ): StepResult? {
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet() 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) return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
} }
@@ -269,6 +269,20 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
when (gateDecision) { when (gateDecision) {
RetryDecision.Retry -> Unit // retry — loop and re-execute RetryDecision.Retry -> Unit // retry — loop and re-execute
RetryDecision.Exhausted -> { 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( decideGateExhaustion(
ctx, stageId, result.gate, result.reason, refreshedState, ctx, stageId, result.gate, result.reason, refreshedState,
)?.let { return it } )?.let { return it }
@@ -81,6 +81,17 @@ internal suspend fun SessionOrchestrator.executeStage(
), ),
) )
} ?: emptyList() } ?: emptyList()
val operatingGuidance = listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = CURATED_STAGE_OPERATING_GUIDANCE,
sourceType = "operatingGuidance",
sourceId = "curated-v1",
tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE),
role = EntryRole.SYSTEM,
),
)
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt // 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 // 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 // recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
@@ -239,7 +250,7 @@ internal suspend fun SessionOrchestrator.executeStage(
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) } ?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
?.let { listOf(it) } ?: emptyList() ?.let { listOf(it) } ?: emptyList()
var accumulatedEntries = stampBuckets( var accumulatedEntries = stampBuckets(
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries + systemPrompt + operatingGuidance + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries + claimedTaskEntries + journalEntries + repoMapEntries + claimedTaskEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries, clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
@@ -390,6 +401,9 @@ internal suspend fun SessionOrchestrator.executeStage(
inferenceResult.response.toolCalls, stageConfig, effectives, inferenceResult.response.toolCalls, stageConfig, effectives,
approvalModeFor(session.state.boundProfile?.approvalMode), approvalModeFor(session.state.boundProfile?.approvalMode),
inferenceResult.response.reasoning) inferenceResult.response.reasoning)
repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
return StageExecutionResult.Failure(reason, retryable = true, gate = "workspace_precondition")
}
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") } val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
if (fatalEntry != null) { if (fatalEntry != null) {
emitProcessResultEvents(sessionId, stageId, stageConfig) emitProcessResultEvents(sessionId, stageId, stageConfig)
@@ -258,6 +258,7 @@ internal suspend fun SessionOrchestrator.evaluateStageContract(
layer = a.layer.name, layer = a.layer.name,
evaluator = a.evaluator.name, evaluator = a.evaluator.name,
passed = v.passed, passed = v.passed,
skipped = v.skipped,
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP), evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
) )
} }
@@ -0,0 +1,44 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
/**
* Repeated attempts to read a missing build prerequisite are no longer mere ReAct noise. Once the
* same build-critical path has been durably blocked three times in one stage, return a retryable
* gate failure so the normal recovery ladder can bootstrap it (or request a different owner).
*/
internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
sessionId: SessionId,
stageId: StageId,
): String? {
val events = eventStore.read(sessionId)
val requests = events
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.associateBy { it.invocationId }
val paths = events
.mapNotNull { it.payload as? ToolCallAssessedEvent }
.filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
.filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
.mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String }
.filter(::isBuildCriticalPath)
val repeated = paths.groupingBy { it }.eachCount().entries
.firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT }
?: return null
return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " +
"(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
}
private fun isBuildCriticalPath(path: String): Boolean {
val name = path.substringAfterLast('/').lowercase()
return name in BUILD_PREREQUISITE_FILENAMES
}
private const val REFERENCE_EXISTS_REPEAT_LIMIT = 3
private val BUILD_PREREQUISITE_FILENAMES = setOf(
"package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
)
+1
View File
@@ -27,6 +27,7 @@ CORREX kernel team.
- `DefaultTransitionResolver` is stateless per call — it reads `EvaluationContext`, not persisted state. - `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). - 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. - `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. - Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
## Verification ## Verification
@@ -52,7 +52,7 @@ data class StageConfig(
// correctness FAIL can block retryably until the review-block budget is spent. Default off. // correctness FAIL can block retryably until the review-block budget is spent. Default off.
val semanticReview: Boolean = false, val semanticReview: Boolean = false,
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's // 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 // 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. // 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 // When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the
+1 -1
View File
@@ -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). - `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. - `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. - `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. - `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. - `epics/`, `modules/`, `diagrams/`, `design/`, `reviews/`, `visual/` — supporting reference material.
+32
View File
@@ -0,0 +1,32 @@
# QA Plan: Stage prompt audition
**Status:** DRAFT
**Run date / operator:** Not run — requires a fixed local model and seed.
**BACKLOG item:** Vikunja #169
## Preconditions
- [ ] Record the model id, model checksum, `context_size`, and sampling config.
- [ ] Use one fixed workspace fixture and deterministic seed per variant.
- [ ] Run `scripts/prompt-audit.sh` and retain its Markdown output with the run artifacts.
## 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 baseline, trimmed, curated-rules, and scope-reworded variants. | Session event logs identify every stage inference and terminal workflow outcome. | Not run |
| 2 | Compare gate pass rate, rejected/wasted tool rounds, retries, token budget use, and truncation. | Per-variant result table derived from events; same fixture and seed. | Not run |
| 3 | Select the smallest improving variant. | A committed finding names the selected variant and evidence; otherwise no prompt expansion lands. | Not run |
## Current static audit finding
The runtime currently has two stage-facing sources: workflow prompt files and the compact `CURATED_STAGE_OPERATING_GUIDANCE` L0 system entry. Retry feedback remains event-derived and only the latest failure is injected. The static inventory is reproducible with `scripts/prompt-audit.sh`; no performance claim is made until the controlled run above is recorded.
## 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,18 @@
# Closed-loop workspace
## Invariant
After every code-producing stage, the workspace has a recorded verification observation. A passing observation is the only state the next implementation stage may treat as known-good; replay reads the event rather than re-running a command.
## Seams
1. Before locking a plan, ground declared writes, referenced paths, and build prerequisites against the workspace/index. Unknown or absent prerequisites return the plan to the architect.
2. For each write-declaring stage, run the appropriate static/build command and record its result. Failures retry or route through recovery before downstream work continues.
3. Repeated hard precondition observations (for example `REFERENCE_EXISTS`) become a stage failure or bootstrap/clarification decision rather than independent rejected tool calls.
## Delivery order
- Per-write-stage build gates and truthful deferred contract verdicts (#162).
- Consume repeated build-critical `REFERENCE_EXISTS` blocks (#163): three blocks for one manifest/config path now create a recovery-eligible precondition failure.
- Ground plan prerequisites before plan lock; then use the verified observation as the next-stage planning baseline.
- Preserve recovery escalation for failures that still cannot reach a verified increment (#165).
@@ -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.
+1
View File
@@ -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. - Add a new workflow example when a major new workflow type ships.
- Prompts referenced by TOML files go in `workflows/prompts/`. - 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. - Do not add configs/plugins/stages stubs speculatively — populate when there is real content.
## Verification ## 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. decompose tasks here — task creation is out of scope for the plan.
- Keep stages small and single-responsibility. Prefer more stages over large monolithic - Keep stages small and single-responsibility. Prefer more stages over large monolithic
prompts. 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 - **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 → 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` `["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes`
+1
View File
@@ -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. - 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). - 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 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`. - `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 ## Work Guidance
@@ -10,6 +10,6 @@ data class ModelDescriptor(
val modelPath: String, val modelPath: String,
val residencyMode: ResidencyMode, val residencyMode: ResidencyMode,
val idleTimeoutMs: Long = 60_000L, val idleTimeoutMs: Long = 60_000L,
val contextSize: Int = 8192, val contextSize: Int = 24_576,
val capabilities: Set<CapabilityScore> = emptySet(), val capabilities: Set<CapabilityScore> = emptySet(),
) )
+1
View File
@@ -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()`. - `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. - `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`. - 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 ## Work Guidance
@@ -127,6 +127,33 @@ class ListDirTool(
} }
private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult { 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) val matcher = GitignoreMatcher(workingDir ?: root, root)
// The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire // 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 // 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 override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE
}) })
out.sort() 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 * gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for
* the real tree. * 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 { 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 (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory")
if (ignoredCount > 0) { if (ignoredCount > 0) {
add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore") add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore")
@@ -240,6 +276,7 @@ class ListDirTool(
private companion object { private companion object {
const val MAX_ENTRIES = 400 const val MAX_ENTRIES = 400
const val MAX_AUTO_DESCENT_DEPTH = 32
const val MAX_SIMILAR_SUGGESTIONS = 3 const val MAX_SIMILAR_SUGGESTIONS = 3
const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2 const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2
const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3 const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3
@@ -49,9 +49,10 @@ class ListDirToolTest {
fun `default is shallow — a bare list_dir does not descend`(): Unit = runBlocking { 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 // 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 // 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") val root = Files.createTempDirectory("listdir")
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") } 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 tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request()) as ToolResult.Success).output val out = (tool.execute(request()) as ToolResult.Success).output
assertTrue(out.contains("src/"), out) assertTrue(out.contains("src/"), out)
@@ -75,9 +76,26 @@ class ListDirToolTest {
fun `non-recursive lists only immediate children`(): Unit = runBlocking { fun `non-recursive lists only immediate children`(): Unit = runBlocking {
val root = Files.createTempDirectory("listdir") val root = Files.createTempDirectory("listdir")
Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") } 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 tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output
assertTrue(out.contains("src/"), out) assertTrue(out.contains("src/"), out)
assertFalse(out.contains("deep"), 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)
}
} }
+1
View File
@@ -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. - `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`. - `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
- `PlanDerivedManifest` exposes the write manifest derived from a plan. - `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 ## Work Guidance
@@ -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 // 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 // accumulates enough to write. Match the static execution baseline. (gemma ctx is 32768, so this
// leaves ample headroom for completion.) // 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 // 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 // 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 // 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 // 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. // paths do — so a docs-only plan is left alone and a code plan is always gated.
val autoGateStageId: String? = val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
if (declaredExpectations.values.all { it == BuildExpectation.NONE }) terminalStageId(plan) else null plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
} else {
emptySet()
}
val stageMap = plan.stages.associate { s -> val stageMap = plan.stages.associate { s ->
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the // `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) }, expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() }, touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() },
buildExpectation = declaredExpectations.getValue(s.id), buildExpectation = declaredExpectations.getValue(s.id),
autoBuildGate = s.id == autoGateStageId, autoBuildGate = s.id in autoGateStages,
semanticReview = s.semanticReview, semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET, tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = defaultStageGeneration, generationConfig = defaultStageGeneration,
@@ -470,9 +470,9 @@ class ExecutionPlanCompilerTest {
"goal": "scaffold a react app", "goal": "scaffold a react app",
"stages": [ "stages": [
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry", { "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", { "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": [ "edges": [
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } }, { "from": "entry", "to": "views", "condition": { "type": "always_true" } },
@@ -482,12 +482,12 @@ class ExecutionPlanCompilerTest {
""".trimIndent() """.trimIndent()
@Test @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 graph = codeCompiler.compile(codePlanNoGate, "wf")
val views = graph.stages[StageId("views")]!! val views = graph.stages[StageId("views")]!!
assertTrue( assertTrue(
views.autoBuildGate, 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( assertEquals(
com.correx.core.transitions.graph.BuildExpectation.NONE, com.correx.core.transitions.graph.BuildExpectation.NONE,
@@ -496,8 +496,8 @@ class ExecutionPlanCompilerTest {
"code module was actually written", "code module was actually written",
) )
assertTrue( assertTrue(
!graph.stages[StageId("entry")]!!.autoBuildGate, graph.stages[StageId("entry")]!!.autoBuildGate,
"intermediate stages are not flagged — the gate runs when the project is whole, not per-stage", "implementation stages must not defer compiler coverage to a terminal verifier",
) )
} }
@@ -531,15 +531,15 @@ class ExecutionPlanCompilerTest {
} }
@Test @Test
fun `exactly the terminal stage of an ungated plan is auto-flagged`() { fun `plan without declared writes has no auto build gate`() {
// The compiler flags the terminal stage regardless of the produced kinds — code-ness is a // A plan with no write declarations cannot promise compiler coverage. Runtime still
// run-time decision (SessionOrchestrator reads the FileWritten manifest), so a non-code plan // inspects actual files for an opted-in gate, but the compiler must not pretend this
// is flagged too but the runtime build gate then skips it. // documentation-shaped plan has implementation-stage verification.
val graph = compiler.compile(validPlan, "wf") val graph = compiler.compile(validPlan, "wf")
assertEquals( assertEquals(
1, 0,
graph.stages.values.count { it.autoBuildGate }, 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",
) )
} }
} }
+27
View File
@@ -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"), 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` /** 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. */ * static gate fails with [staticOutput] — so route-to-owner can resolve `impl` as the file author. */
private fun buildMultiToolOrchestrator( private fun buildMultiToolOrchestrator(
@@ -520,6 +541,33 @@ class RecoveryRoutingTest {
assertTrue(tickets.take(firstEscalatedIdx).none { it.escalated }, "owner routes come before escalation") 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; // 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 // 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. // re-scaffolds and stubs the real file). Same topology as ownerGraph otherwise.