Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -654,6 +654,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.
|
||||
|
||||
@@ -133,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,
|
||||
@@ -409,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,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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -23,6 +23,8 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
|
||||
- Config is read from disk; it is not event-sourced. Do not add event/state/reducer structures here.
|
||||
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
|
||||
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
|
||||
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
|
||||
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ object ConfigLoader {
|
||||
private const val DEFAULT_CONVERSATION_KEEP_LAST = 6
|
||||
private const val DEFAULT_RETRIEVAL_K = 5
|
||||
private const val DEFAULT_TOKEN_BUDGET = 4096
|
||||
private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192
|
||||
private const val DEFAULT_MODEL_CONTEXT_SIZE = 24_576
|
||||
private const val DEFAULT_PROJECT_MEMORY_K = 5
|
||||
private const val DEFAULT_PROJECT_MAX_DEPTH = 4
|
||||
private const val DEFAULT_PROJECT_INJECT_TOP_K = 30
|
||||
@@ -735,6 +735,14 @@ object ConfigLoader {
|
||||
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
|
||||
)
|
||||
|
||||
val gitSection = sections["git"] ?: emptyMap()
|
||||
val git = GitConfig(
|
||||
enabled = asBoolean(gitSection["enabled"], false),
|
||||
remote = asString(gitSection["remote"], "origin"),
|
||||
baseBranch = asString(gitSection["base_branch"], "main"),
|
||||
author = asString(gitSection["author"], ""),
|
||||
)
|
||||
|
||||
return CorrexConfig(
|
||||
server = server,
|
||||
tui = tui,
|
||||
@@ -749,6 +757,7 @@ object ConfigLoader {
|
||||
personalization = personalization,
|
||||
orchestration = orchestration,
|
||||
sampling = sampling,
|
||||
git = git,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,21 @@ data class CorrexConfig(
|
||||
val orchestration: OrchestrationKnobs = OrchestrationKnobs(),
|
||||
val sampling: SamplingConfig = SamplingConfig(),
|
||||
val health: HealthConfig = HealthConfig(),
|
||||
val git: GitConfig = GitConfig(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Optional transport for a server-owned checkout. Each run executes on a `run/<sessionId>` branch
|
||||
* based on [baseBranch] and pushes that branch on terminal completion; the working directory stays
|
||||
* a real local path, never a remote URL or mounted client filesystem.
|
||||
*/
|
||||
@Serializable
|
||||
data class GitConfig(
|
||||
val enabled: Boolean = false,
|
||||
val remote: String = "origin",
|
||||
val baseBranch: String = "main",
|
||||
/** Optional Git author value, for example `Correx <correx@example.invalid>`. */
|
||||
val author: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -283,7 +298,8 @@ data class L3Config(
|
||||
data class ModelConfig(
|
||||
val id: String,
|
||||
val modelPath: String,
|
||||
val contextSize: Int = 8192,
|
||||
/** Default window for implementation stages; leave headroom above their 24K prompt budget. */
|
||||
val contextSize: Int = 24_576,
|
||||
val params: Map<String, String> = emptyMap(),
|
||||
val capabilities: Map<String, Double> = emptyMap(),
|
||||
)
|
||||
|
||||
@@ -109,6 +109,12 @@ object CorrexConfigWriter {
|
||||
cfg.sampling.minP?.let { b.kv("min_p", it) }
|
||||
cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", it) }
|
||||
|
||||
b.section("git")
|
||||
b.kv("enabled", cfg.git.enabled)
|
||||
b.kv("remote", str(cfg.git.remote))
|
||||
b.kv("base_branch", str(cfg.git.baseBranch))
|
||||
b.kv("author", str(cfg.git.author))
|
||||
|
||||
b.section("personalization")
|
||||
b.kv("enabled", cfg.personalization.enabled)
|
||||
b.kv("learn", cfg.personalization.learn)
|
||||
|
||||
@@ -434,7 +434,7 @@ class ConfigLoaderTest {
|
||||
assertEquals(1, result.models.size)
|
||||
assertEquals("local-model", result.models[0].id)
|
||||
assertEquals("/models/local.gguf", result.models[0].modelPath)
|
||||
assertEquals(8192, result.models[0].contextSize)
|
||||
assertEquals(24_576, result.models[0].contextSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -37,6 +37,7 @@ class CorrexConfigWriterTest {
|
||||
),
|
||||
personalization = PersonalizationConfig(enabled = true, learn = true),
|
||||
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
|
||||
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
|
||||
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
|
||||
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
|
||||
providers = listOf(
|
||||
|
||||
@@ -27,6 +27,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
|
||||
- **SILENT FAILURE TRAP**: After adding any `EventPayload` subclass, immediately add it to `Serialization.kt` `eventModule` block. Run `./gradlew check` to verify. Tests may pass without it but runtime replay will fail silently.
|
||||
- `AnyMapSerializer` — custom serializer for `Map<String, Any?>`; use it for dynamic payloads, don't roll another.
|
||||
- Event classes are `@Serializable data class` with no mutable state. No methods beyond data accessors.
|
||||
- `RunBranchPushedEvent` records an optional server Git transport push only after it succeeds; its branch/base/head SHAs are observations, not values replay recalculates.
|
||||
- `RepoMapEntry.descriptor` is a bounded source-purpose observation recorded with the repo map and used when constructing semantic L3 embeddings.
|
||||
- Do not add domain logic to events. They are records, not actors.
|
||||
- `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection.
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ data class ContractAssertionResult(
|
||||
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
|
||||
val evaluator: String,
|
||||
val passed: Boolean,
|
||||
/** True when this assertion is intentionally deferred to another authoritative gate. */
|
||||
val skipped: Boolean = false,
|
||||
/** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
|
||||
val evidence: String,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,19 @@ data class WorkflowFailedEvent(
|
||||
val retryExhausted: Boolean,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Observation recorded only after the optional server Git transport has pushed a terminal run
|
||||
* branch. This keeps the reviewable branch reference replayable without re-running Git.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("RunBranchPushed")
|
||||
data class RunBranchPushedEvent(
|
||||
val sessionId: SessionId,
|
||||
val branch: String,
|
||||
val baseSha: String,
|
||||
val headSha: String,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Records that the operator approved access to a specific path OUTSIDE the workspace root
|
||||
* (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace
|
||||
|
||||
@@ -11,6 +11,8 @@ data class RepoMapEntry(
|
||||
val path: String,
|
||||
val score: Double,
|
||||
val symbols: List<String> = emptyList(),
|
||||
/** Bounded deterministic source-purpose text used for semantic repo retrieval. */
|
||||
val descriptor: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
import com.correx.core.events.events.TaskClaimedEvent
|
||||
import com.correx.core.events.events.TaskReleasedEvent
|
||||
@@ -151,6 +152,7 @@ val eventModule = SerializersModule {
|
||||
subclass(OrchestrationResumedEvent::class)
|
||||
subclass(OrchestrationPausedEvent::class)
|
||||
subclass(WorkflowStartedEvent::class)
|
||||
subclass(RunBranchPushedEvent::class)
|
||||
subclass(WorkflowFailedEvent::class)
|
||||
subclass(WorkflowCompletedEvent::class)
|
||||
subclass(RetryAttemptedEvent::class)
|
||||
|
||||
@@ -20,6 +20,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
|
||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
||||
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
|
||||
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
|
||||
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
|
||||
- Every stage receives a small curated operating-guidance system entry: verify observed state, create required project setup, and resolve necessary scope edges without re-deliberating.
|
||||
- `JournalCompactionService` — triggers journal compaction and emits `JournalCompactedEvent`.
|
||||
- `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring.
|
||||
- `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning.
|
||||
|
||||
+7
@@ -69,8 +69,15 @@ internal val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
|
||||
"execution" to "file_write",
|
||||
"contract" to "file_write",
|
||||
"static_analysis" to "file_write",
|
||||
"workspace_precondition" to "file_write",
|
||||
)
|
||||
|
||||
internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance
|
||||
Verify the current workspace before declaring completion. Read before modifying existing files.
|
||||
When the authoritative intent plainly requires project setup, creating its manifest, configuration,
|
||||
or entry file is in scope; do not spend turns re-deciding that settled boundary. Re-observe facts
|
||||
instead of assuming them, and use the exact gate/tool feedback to make the smallest effective fix."""
|
||||
|
||||
// Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path).
|
||||
internal fun ticketCategory(gate: String): String = when (gate) {
|
||||
"plan_compile" -> "planning"
|
||||
|
||||
+4
-3
@@ -44,8 +44,9 @@ internal suspend fun DefaultSessionOrchestrator.retryStageOrFail(
|
||||
* capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal),
|
||||
* or null to fall through to the normal per-gate retry path. Null in every backward-compatible
|
||||
* case: gate not capability-gated, stage already has the capability, or no recovery stage exists.
|
||||
* or null to fall through to the normal per-gate retry path. A stage that already holds the
|
||||
* capability retries in place first; its unchanged-fingerprint retry exhaustion is separately
|
||||
* routed from the step loop, because capability possession does not prove the owner can apply it.
|
||||
*/
|
||||
|
||||
internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
|
||||
@@ -56,7 +57,7 @@ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery(
|
||||
): StepResult? {
|
||||
val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null
|
||||
val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet()
|
||||
if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid
|
||||
if (requiredCapability in stageTools) return null // retry in place before no-progress exhaustion
|
||||
return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state)
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -269,6 +269,20 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||
when (gateDecision) {
|
||||
RetryDecision.Retry -> Unit // retry — loop and re-execute
|
||||
RetryDecision.Exhausted -> {
|
||||
// A stage may hold the nominal capability yet repeatedly fail to apply it
|
||||
// (scope paralysis / frozen ReAct loop). Exhaustion is only returned for an
|
||||
// unchanged fingerprint, so hand that no-progress dead-end to the
|
||||
// intent-holder rather than declaring the workflow failed in place.
|
||||
GATE_REQUIRED_CAPABILITY[result.gate]?.let { capability ->
|
||||
routeToRecovery(
|
||||
ctx,
|
||||
stageId,
|
||||
result.gate,
|
||||
capability,
|
||||
result.reason,
|
||||
refreshedState,
|
||||
)?.let { return it }
|
||||
}
|
||||
decideGateExhaustion(
|
||||
ctx, stageId, result.gate, result.reason, refreshedState,
|
||||
)?.let { return it }
|
||||
|
||||
+15
-1
@@ -81,6 +81,17 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
val operatingGuidance = listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = CURATED_STAGE_OPERATING_GUIDANCE,
|
||||
sourceType = "operatingGuidance",
|
||||
sourceId = "curated-v1",
|
||||
tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE),
|
||||
role = EntryRole.SYSTEM,
|
||||
),
|
||||
)
|
||||
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
|
||||
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
|
||||
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
|
||||
@@ -239,7 +250,7 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
systemPrompt + operatingGuidance + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||
@@ -390,6 +401,9 @@ internal suspend fun SessionOrchestrator.executeStage(
|
||||
inferenceResult.response.toolCalls, stageConfig, effectives,
|
||||
approvalModeFor(session.state.boundProfile?.approvalMode),
|
||||
inferenceResult.response.reasoning)
|
||||
repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason ->
|
||||
return StageExecutionResult.Failure(reason, retryable = true, gate = "workspace_precondition")
|
||||
}
|
||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||
if (fatalEntry != null) {
|
||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||
|
||||
+1
@@ -258,6 +258,7 @@ internal suspend fun SessionOrchestrator.evaluateStageContract(
|
||||
layer = a.layer.name,
|
||||
evaluator = a.evaluator.name,
|
||||
passed = v.passed,
|
||||
skipped = v.skipped,
|
||||
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
|
||||
)
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
/**
|
||||
* Repeated attempts to read a missing build prerequisite are no longer mere ReAct noise. Once the
|
||||
* same build-critical path has been durably blocked three times in one stage, return a retryable
|
||||
* gate failure so the normal recovery ladder can bootstrap it (or request a different owner).
|
||||
*/
|
||||
internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
): String? {
|
||||
val events = eventStore.read(sessionId)
|
||||
val requests = events
|
||||
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId }
|
||||
.associateBy { it.invocationId }
|
||||
val paths = events
|
||||
.mapNotNull { it.payload as? ToolCallAssessedEvent }
|
||||
.filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK }
|
||||
.filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } }
|
||||
.mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String }
|
||||
.filter(::isBuildCriticalPath)
|
||||
val repeated = paths.groupingBy { it }.eachCount().entries
|
||||
.firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT }
|
||||
?: return null
|
||||
return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " +
|
||||
"(${repeated.value} blocked attempts). Create or repair the project setup before continuing."
|
||||
}
|
||||
|
||||
private fun isBuildCriticalPath(path: String): Boolean {
|
||||
val name = path.substringAfterLast('/').lowercase()
|
||||
return name in BUILD_PREREQUISITE_FILENAMES
|
||||
}
|
||||
|
||||
private const val REFERENCE_EXISTS_REPEAT_LIMIT = 3
|
||||
private val BUILD_PREREQUISITE_FILENAMES = setOf(
|
||||
"package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml",
|
||||
)
|
||||
@@ -27,6 +27,7 @@ CORREX kernel team.
|
||||
- `DefaultTransitionResolver` is stateless per call — it reads `EvaluationContext`, not persisted state.
|
||||
- Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip).
|
||||
- `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session.
|
||||
- `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists.
|
||||
- Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -52,7 +52,7 @@ data class StageConfig(
|
||||
// correctness FAIL can block retryably until the review-block budget is spent. Default off.
|
||||
val semanticReview: Boolean = false,
|
||||
// Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's
|
||||
// terminal stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
|
||||
// write-declaring plan stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every
|
||||
// file-writing stage as the generic `file_written` kind (never a typed code kind) and rarely sets
|
||||
// build_expectation, so a plan that scaffolds real code would otherwise never hit an execution gate.
|
||||
// When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the
|
||||
|
||||
+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,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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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