wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment

This branch's uncommitted WIP, committed together (entangled at file level).
Distinct pieces of work:

Freestyle QA fixes (this session):
- FileEditTool: pre-validate replace anchor in validateRequest — reject a
  missing/ambiguous target BEFORE the approval gate, mirroring read/write's
  file-not-found / read-before-write pre-checks. Shared not-found/ambiguous
  messages between validate and execute so they can't drift.
- PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded,
  repoMapPaths is "unknown" not "empty workspace" — skip scope grounding
  (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely
  rejected. Build-manifest check still runs.
- FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a
  session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the
  COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted).
- ServerModule: resolve project-memory workspace root from the session's bound
  workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing
  the workspace-binding divergence (correx vs empty scratch dir). Retire tracked
  in Vikunja #266.
- LaunchRegistrationRaceTest: join registered jobs before asserting launchCount
  — computeIfAbsent returns the Job immediately but the fire-and-forget launch
  body lagged awaitAll (the 49-vs-50 flake).

ACR concept-compiler experiment (pre-existing WIP on this branch):
- ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts),
  LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace,
  config surface, workflow prompts/schemas, orchestrator advance-don't-rerun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 01:20:37 +04:00
parent 7b90944b61
commit 1b58bc325e
57 changed files with 1205 additions and 115 deletions
+3 -1
View File
@@ -15,7 +15,9 @@ Adapter for `core:transitions` and `core:inference` workflow interfaces. Depends
- `ExecutionPlanCompiler` converts the parsed `ExecutionPlanModel` into a runnable plan for `core:transitions`; compilation is deterministic given the same input model.
- `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
- `PlanDerivedManifest` exposes the write manifest derived from a plan.
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage and the graph-terminal stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
- When a plan declares a `dod` producer, freestyle implementation/review stages consume that session-scoped artifact; reviewer prompts are compiler-bound to its reviewer-owned criteria.
- `Lsp4jDiagnosticsRunner` uses LSP 3.17 pull diagnostics for registered languages and degrades to the static one-shot floor when a server is unavailable.
## Work Guidance
+2
View File
@@ -9,6 +9,8 @@ dependencies {
implementation(project(":core:events"))
implementation(project(":core:artifacts"))
implementation(project(":core:tools"))
implementation(project(":core:kernel"))
implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
testImplementation "org.junit.jupiter:junit-jupiter"
@@ -19,6 +19,8 @@ import java.nio.file.Path
private const val TERMINAL = "done"
private const val RECOVERY_STAGE = "recovery"
private const val DOD_ARTIFACT = "dod"
private const val STATIC_FLOOR_COMMAND = "correx-static-floor"
// Synthesized recovery stage prompt. Freestyle plans are LLM-emitted and never declare a recovery
// stage, but the kernel's retry-agency guard routes a write-less stage's unfixable gate failure to
@@ -85,7 +87,16 @@ class ExecutionPlanCompiler(
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build()
fun compile(planJson: String, workflowId: String): WorkflowGraph {
/**
* @param sessionArtifactIds artifact ids already produced earlier in the same freestyle session
* (e.g. the planning-phase `dod`). The execution plan never re-declares these, so they must be
* passed in for the cross-workflow `needs` seam (#264) to thread the DoD into impl/review stages.
*/
fun compile(
planJson: String,
workflowId: String,
sessionArtifactIds: Set<String> = emptySet(),
): WorkflowGraph {
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
@@ -115,10 +126,13 @@ class ExecutionPlanCompiler(
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
// paths do — so a docs-only plan is left alone and a code plan is always gated.
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan)
} else {
emptySet()
}
val sessionArtifacts =
plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds
val stageMap = plan.stages.associate { s ->
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
@@ -137,7 +151,7 @@ class ExecutionPlanCompiler(
).sorted()
StageId(s.id) to StageConfig(
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
needs = s.needs.map { ArtifactId(it) }.toSet(),
needs = (s.needs + requiredSessionArtifacts(s, sessionArtifacts)).map { ArtifactId(it) }.toSet(),
// Every stage gets the full registered tool universe. The architect's per-stage
// `tools` pick was advisory and mis-scoped stages (e.g. an edit-shaped stage granted
// file_write but not file_edit → forced full-file rewrites; a repair stage unable to
@@ -146,6 +160,7 @@ class ExecutionPlanCompiler(
// architect's pick only when no universe was supplied (bare-constructor unit tests).
allowedTools = knownTools.ifEmpty { s.tools.toSet() },
writeManifest = writeManifest,
staticAnalysis = staticFloorCommands(writeManifest),
// Option A: the concrete (non-glob) subset of the declared writes are expected files
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
// not existence guarantees, so they are excluded here.
@@ -156,7 +171,7 @@ class ExecutionPlanCompiler(
semanticReview = s.semanticReview,
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
generationConfig = defaultStageGeneration,
metadata = mapOf("promptInline" to s.prompt),
metadata = mapOf("role" to s.role, "promptInline" to reviewerBoundPrompt(s, DOD_ARTIFACT in sessionArtifacts)),
)
}
// Inject a recovery stage unless the plan already declares one. Reached only by the kernel's
@@ -291,6 +306,31 @@ class ExecutionPlanCompiler(
private fun terminalStageId(plan: ExecutionPlanModel): String =
plan.edges.firstOrNull { it.to == TERMINAL }?.from ?: plan.stages.last().id
private fun requiredSessionArtifacts(stage: PlanStage, sessionArtifacts: Set<String>): List<String> =
if (
DOD_ARTIFACT in sessionArtifacts &&
stage.role.lowercase() in setOf("impl", "implementer", "review", "reviewer")
) {
listOf(DOD_ARTIFACT)
} else {
emptyList()
}
private fun reviewerBoundPrompt(stage: PlanStage, dodPresent: Boolean): String {
if (!dodPresent || stage.role.lowercase() !in setOf("review", "reviewer")) return stage.prompt
return stage.prompt + "\n\n" +
"The `dod` input artifact is your sole acceptance rubric. Judge only criteria tagged " +
"`verified_by: reviewer`; deterministic gates own `verified_by: gate`. Approve if and only if " +
"every reviewer criterion is met and no `out_of_scope` item was introduced. On rejection, " +
"cite the unmet criterion ids. Never add or imply criteria outside the DoD."
}
private fun staticFloorCommands(writeManifest: List<String>): List<String> {
return writeManifest.takeIf { it.isNotEmpty() }
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
?: emptyList()
}
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
private fun validateReachability(
@@ -10,6 +10,7 @@ data class ExecutionPlanModel(
data class PlanStage(
val id: String = "",
val role: String = "",
val prompt: String = "",
val produces: String = "",
val kind: String? = null,
@@ -0,0 +1,142 @@
package com.correx.infrastructure.workflow
import com.correx.core.events.events.LspDiagnostic
import com.correx.core.kernel.orchestration.LspDiagnosticsRequest
import com.correx.core.kernel.orchestration.LspDiagnosticsResult
import com.correx.core.kernel.orchestration.LspDiagnosticsRunner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.DidOpenTextDocumentParams
import org.eclipse.lsp4j.DocumentDiagnosticParams
import org.eclipse.lsp4j.InitializeParams
import org.eclipse.lsp4j.InitializedParams
import org.eclipse.lsp4j.MessageActionItem
import org.eclipse.lsp4j.MessageParams
import org.eclipse.lsp4j.PublishDiagnosticsParams
import org.eclipse.lsp4j.ShowMessageRequestParams
import org.eclipse.lsp4j.TextDocumentIdentifier
import org.eclipse.lsp4j.TextDocumentItem
import org.eclipse.lsp4j.launch.LSPLauncher
import org.eclipse.lsp4j.services.LanguageClient
import java.nio.file.Files
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
data class LspServerSpec(val id: String, val command: List<String>, val languageId: String)
class LspServerRegistry(
private val byExtension: Map<String, LspServerSpec> = defaults,
) {
fun serverFor(path: String): LspServerSpec? = byExtension[path.substringAfterLast('.', "").lowercase()]
companion object {
private val typescript = LspServerSpec(
"tsserver",
listOf("typescript-language-server", "--stdio"),
"typescriptreact",
)
private val rust = LspServerSpec("rust-analyzer", listOf("rust-analyzer"), "rust")
private val go = LspServerSpec("gopls", listOf("gopls"), "go")
private val clangd = LspServerSpec("clangd", listOf("clangd"), "cpp")
val defaults = mapOf(
"ts" to typescript.copy(languageId = "typescript"),
"tsx" to typescript,
"js" to typescript.copy(languageId = "javascript"),
"jsx" to typescript.copy(languageId = "javascriptreact"),
"rs" to rust,
"go" to go,
"c" to clangd.copy(languageId = "c"),
"cc" to clangd,
"cpp" to clangd,
"h" to clangd.copy(languageId = "c"),
"hpp" to clangd,
)
}
}
/** LSP 3.17 pull-diagnostics adapter. Missing servers degrade to the static one-shot floor. */
class Lsp4jDiagnosticsRunner(
private val registry: LspServerRegistry = LspServerRegistry(),
private val timeoutSeconds: Long = 30,
) : LspDiagnosticsRunner {
override suspend fun pull(request: LspDiagnosticsRequest): LspDiagnosticsResult = withContext(Dispatchers.IO) {
val groups = request.paths
.mapNotNull { path -> registry.serverFor(path)?.let { it to path } }
.groupBy({ it.first }, { it.second })
if (groups.isEmpty()) {
return@withContext LspDiagnosticsResult(skippedReason = "no language server registered for touched files")
}
val diagnostics = mutableListOf<LspDiagnostic>()
val skipped = mutableListOf<String>()
groups.forEach { (server, paths) ->
runCatching { diagnostics += pullFromServer(request, server, paths) }
.onFailure {
if (it is CancellationException) throw it
skipped += "${server.id}: ${it.message ?: it::class.simpleName}"
}
}
LspDiagnosticsResult(
server = groups.keys.joinToString(",") { it.id },
diagnostics = diagnostics,
skippedReason = skipped.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
private fun pullFromServer(
request: LspDiagnosticsRequest,
spec: LspServerSpec,
paths: List<String>,
): List<LspDiagnostic> {
val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start()
val stderrDrain = Thread.ofVirtual().start { process.errorStream.bufferedReader().use { it.readText() } }
val launcher = LSPLauncher.createClientLauncher(QuietLanguageClient, process.inputStream, process.outputStream)
val listening = launcher.startListening()
val server = launcher.remoteProxy
return try {
server.initialize(
InitializeParams().apply {
rootUri = request.workspaceRoot.toUri().toString()
capabilities = ClientCapabilities()
},
).get(timeoutSeconds, TimeUnit.SECONDS)
server.initialized(InitializedParams())
paths.flatMap { path ->
val file = request.workspaceRoot.resolve(path).normalize()
val uri = file.toUri().toString()
server.textDocumentService.didOpen(
DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))),
)
val report = server.textDocumentService.diagnostic(
DocumentDiagnosticParams(TextDocumentIdentifier(uri)),
).get(timeoutSeconds, TimeUnit.SECONDS)
report.relatedFullDocumentDiagnosticReport.items.orEmpty().map { diagnostic ->
LspDiagnostic(
path = path,
line = diagnostic.range.start.line,
character = diagnostic.range.start.character,
severity = diagnostic.severity?.name?.lowercase() ?: "error",
code = diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() },
message = diagnostic.message,
)
}
}
} finally {
runCatching { server.shutdown().get(timeoutSeconds, TimeUnit.SECONDS) }
server.exit()
listening.cancel(true)
process.destroy()
stderrDrain.join(TimeUnit.SECONDS.toMillis(1))
}
}
private object QuietLanguageClient : LanguageClient {
override fun telemetryEvent(`object`: Any?) = Unit
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit
override fun showMessage(messageParams: MessageParams) = Unit
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
CompletableFuture.completedFuture(null)
override fun logMessage(message: MessageParams) = Unit
}
}
@@ -35,6 +35,13 @@ object PlanGrounder {
graph: WorkflowGraph,
repoMapPaths: Set<String>,
profileCommands: Map<String, String>,
// False = no repo scan was recorded for this session, so [repoMapPaths] is "unknown", not
// "empty workspace". Scope grounding proves a path ABSENT, which an unknown set can't do —
// running it against a missing scan flags every real path as nonexistent (the apps/server
// "doesn't exist?!" false reject). Skip scope grounding then; the build-manifest check still
// runs (it degrades safely via manifestProducedByPlan). A recorded scan with 0 entries is a
// genuine greenfield workspace — scanned=true, so scope grounding still catches a missing scaffold.
scanned: Boolean = true,
): PlanGroundingResult {
val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
val manifestProducedByPlan = graph.stages.values
@@ -60,14 +67,16 @@ object PlanGrounder {
// scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
// under is grounded on a directory that will never exist — the exact session-a35cf1e3
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
cfg.touches
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
.forEach { glob ->
add(
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
)
}
if (scanned) {
cfg.touches
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
.forEach { glob ->
add(
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
)
}
}
}
}
@@ -37,7 +37,7 @@ object PlanLinter {
* planning phase, not by any plan stage. The architect prompt explicitly allows `needs` to
* reference these (notably `analysis`), so the linter must treat them as available producers.
*/
private val seedArtifacts = setOf("analysis")
private val seedArtifacts = setOf("analysis", "dod")
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
PlanLintResult(
@@ -499,6 +499,63 @@ class ExecutionPlanCompilerTest {
graph.stages[StageId("entry")]!!.autoBuildGate,
"implementation stages must not defer compiler coverage to a terminal verifier",
)
assertTrue(graph.stages[StageId("entry")]!!.staticAnalysis.single().contains("src/main.tsx"))
assertTrue(graph.stages[StageId("views")]!!.staticAnalysis.single().contains("src/App.tsx"))
}
@Test
fun `non-writing review terminal receives the auto build gate`() {
val planned = """
{
"goal": "implement then review",
"stages": [
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
"kind": "react_entry", "writes": ["src/main.tsx"] },
{ "id": "review", "role": "reviewer", "prompt": "review against dod", "produces": "verdict",
"kind": "react_component", "needs": ["code"] }
],
"edges": [
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = codeCompiler.compile(planned, "wf")
assertTrue(graph.stages.getValue(StageId("review")).autoBuildGate)
// No dod in session → nothing to thread (execution plan never re-produces it).
assertTrue(graph.stages.getValue(StageId("review")).needs.none { it.value == "dod" })
// Planning-phase dod present in the session → threaded into impl + review needs (#264 seam).
val withDod = codeCompiler.compile(planned, "wf", sessionArtifactIds = setOf("dod"))
assertTrue(withDod.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
assertTrue(withDod.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
}
@Test
fun `implementation and review consume dod only when the plan produces it`() {
val planned = """
{
"goal": "define, implement, and review",
"stages": [
{ "id": "analyst", "role": "analyst", "prompt": "define dod", "produces": "dod",
"kind": "react_component" },
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
"kind": "react_entry", "writes": ["src/main.tsx"] },
{ "id": "review", "role": "reviewer", "prompt": "review", "produces": "verdict",
"kind": "react_component", "needs": ["code"] }
],
"edges": [
{ "from": "analyst", "to": "impl", "condition": { "type": "always_true" } },
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = codeCompiler.compile(planned, "wf-with-dod")
assertTrue(graph.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
assertTrue(graph.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
}
@Test
@@ -28,6 +28,7 @@ class FreestylePlanningWorkflowTest {
private val registry = DefaultArtifactKindRegistry().apply {
register(llmKind("discovery"))
register(llmKind("analysis"))
register(llmKind("dod"))
register(llmKind("execution_plan"))
}
@@ -64,7 +65,7 @@ class FreestylePlanningWorkflowTest {
"architect must produce execution_plan",
)
assertTrue(architect.needs.map { it.value }.contains("analysis"), "architect needs analysis")
assertTrue(architect.needs.map { it.value }.contains("dod"), "architect needs the fixed DoD")
val analystToArchitect = graph.transitions.first { it.from == StageId("analyst") }
assertEquals(StageId("architect"), analystToArchitect.to)
@@ -0,0 +1,22 @@
package com.correx.infrastructure.workflow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LspServerRegistryTest {
private val registry = LspServerRegistry()
@Test
fun `registry maps language extensions to servers`() {
assertEquals("tsserver", registry.serverFor("src/App.tsx")?.id)
assertEquals("rust-analyzer", registry.serverFor("src/main.rs")?.id)
assertEquals("gopls", registry.serverFor("cmd/main.go")?.id)
assertEquals("clangd", registry.serverFor("src/main.cpp")?.id)
}
@Test
fun `unknown extension falls through`() {
assertNull(registry.serverFor("README.md"))
}
}
@@ -65,6 +65,13 @@ class PlanGrounderTest {
assertTrue(r.findings.single().contains("frontend/**"))
}
@Test
fun `scanned=false skips scope grounding — a missing scan can't prove a path absent`() {
val g = graph("impl" to StageConfig(touches = listOf("frontend/**", "apps/server/**")))
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds, scanned = false)
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
}
@Test
fun `a scope satisfied by an existing repo path grounds`() {
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))