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 /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)
|
||||||
|
}
|
||||||
+1
-1
@@ -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(
|
||||||
|
|||||||
+3
-2
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
+4
-4
@@ -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)
|
||||||
|
|||||||
+4
-4
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+7
@@ -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"
|
||||||
|
|||||||
+4
-3
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
@@ -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 }
|
||||||
|
|||||||
+15
-1
@@ -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)
|
||||||
|
|||||||
+1
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.
|
- `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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
- 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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -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
|
||||||
|
|||||||
+19
-1
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+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
|
// 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,
|
||||||
|
|||||||
+12
-12
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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"),
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user