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:
@@ -21,6 +21,7 @@ All sources under `apps/server/src/`.
|
||||
- `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.
|
||||
- At boot, `tools.workspace_root` is the authoritative tool jail and project-observation root. A configured `tools.working_dir` may only remain distinct when it is contained by that root; an outside value is clamped to `workspace_root`. Project memory and repo-map indexing are also rebound to `workspace_root`, so a stale `[project].root` cannot inject files from outside the session workspace.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.correx.apps.server
|
||||
|
||||
import com.correx.core.config.ProjectConfig
|
||||
import java.nio.file.Path
|
||||
|
||||
internal data class BootWorkspace(
|
||||
val workspaceRoot: Path,
|
||||
val workingDir: Path,
|
||||
val workingDirWasClamped: Boolean,
|
||||
)
|
||||
|
||||
internal fun resolveBootWorkspace(
|
||||
explicitWorkspaceRoot: Path?,
|
||||
explicitWorkingDir: Path?,
|
||||
processWorkingDir: Path,
|
||||
): BootWorkspace {
|
||||
val workspaceRoot = (explicitWorkspaceRoot ?: explicitWorkingDir ?: processWorkingDir)
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
val requestedWorkingDir = (explicitWorkingDir ?: workspaceRoot)
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
val workingDirIsContained = requestedWorkingDir.startsWith(workspaceRoot)
|
||||
|
||||
return BootWorkspace(
|
||||
workspaceRoot = workspaceRoot,
|
||||
workingDir = requestedWorkingDir.takeIf { workingDirIsContained } ?: workspaceRoot,
|
||||
workingDirWasClamped = !workingDirIsContained,
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep repo-map observation and L3 project memory inside the authoritative boot workspace. */
|
||||
internal fun ProjectConfig.boundToWorkspace(workspaceRoot: Path): ProjectConfig = copy(
|
||||
root = workspaceRoot.toAbsolutePath().normalize().toString(),
|
||||
)
|
||||
@@ -83,6 +83,7 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.InfrastructureModule
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||
import com.correx.infrastructure.workflow.Lsp4jDiagnosticsRunner
|
||||
import com.correx.infrastructure.workflow.PlanLinter
|
||||
import com.correx.infrastructure.inference.CapabilityAwareRoutingStrategy
|
||||
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
||||
@@ -384,6 +385,7 @@ fun main() {
|
||||
workspacePolicy = workspacePolicy,
|
||||
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
||||
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
||||
lspDiagnosticsRunner = Lsp4jDiagnosticsRunner(),
|
||||
contractAssertionEvaluator = FileSystemContractEvaluator(),
|
||||
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
||||
)
|
||||
@@ -464,6 +466,7 @@ fun main() {
|
||||
maxClarificationRounds = maxClarificationRounds,
|
||||
reviewBlockMinConfidence = reviewBlockMinConfidence,
|
||||
reviewBlockRetryCap = reviewBlockRetryCap,
|
||||
reviewLoopMaxCycles = reviewLoopMaxCycles,
|
||||
defaultMaxRefinement = defaultMaxRefinement,
|
||||
recoveryRouteBudget = recoveryRouteBudget,
|
||||
intentRouteBudget = intentRouteBudget,
|
||||
|
||||
@@ -380,10 +380,11 @@ class ServerModule(
|
||||
// Record the repo map + seed prior-session memory before the run so stages
|
||||
// see both in context.
|
||||
projectMemory?.let { pm ->
|
||||
val root = sessionWorkspaceRoot(sessionId)
|
||||
runCatching {
|
||||
pm.observeAndRecord(sessionId, pm.repoRoot())
|
||||
pm.indexAndRecord(sessionId, pm.repoRoot())
|
||||
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
||||
pm.observeAndRecord(sessionId, root)
|
||||
pm.indexAndRecord(sessionId, root)
|
||||
pm.retrieveAndSeed(sessionId, root)
|
||||
}
|
||||
}
|
||||
// Bind operator profile snapshot as an event so replay reads the recorded
|
||||
@@ -416,7 +417,7 @@ class ServerModule(
|
||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||
freestyleHandoff(sessionId, graph, result)
|
||||
// Distil this run's decisions into durable project memory on completion.
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, sessionWorkspaceRoot(sessionId)) }
|
||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||
operatorProfile?.let { profile ->
|
||||
profileAdaptationService?.let { svc ->
|
||||
@@ -498,6 +499,17 @@ class ServerModule(
|
||||
* router chat triage, and replay read the recorded snapshot, never the live file
|
||||
* (invariants #8/#9). Shared by the workflow path and the chat-session path.
|
||||
*/
|
||||
/**
|
||||
* The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
|
||||
* use. The repo-map/index/L3-memory pipeline MUST key off this, not [ProjectMemoryService.repoRoot]
|
||||
* (a session-independent config/cwd default): when they diverge the repo map is computed for a
|
||||
* different tree than the session operates in, so grounding "proves" real paths absent
|
||||
* (session 5fe538f5, 2026-07-19). Falls back to the server default only when unbound.
|
||||
*/
|
||||
private fun sessionWorkspaceRoot(sessionId: SessionId): String =
|
||||
runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
|
||||
.getOrNull() ?: projectMemory?.repoRoot() ?: "."
|
||||
|
||||
suspend fun bindProjectProfile(sessionId: SessionId) {
|
||||
val workspaceRoot = runCatching {
|
||||
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.apps.server.freestyle
|
||||
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.CapabilityGapDetectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapVerdict
|
||||
@@ -12,6 +13,7 @@ import com.correx.core.events.events.PlanGroundingVerdict
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.infrastructure.workflow.PlanGrounder
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.infrastructure.workflow.CapabilityGap
|
||||
@@ -62,7 +64,7 @@ class FreestyleDriver(
|
||||
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
|
||||
return
|
||||
}
|
||||
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
|
||||
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId)) }
|
||||
.getOrElse {
|
||||
log.error("freestyle: plan failed to compile: {}", it.message)
|
||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||
@@ -137,11 +139,17 @@ class FreestyleDriver(
|
||||
*/
|
||||
fun compiledGraph(sessionId: SessionId): WorkflowGraph? {
|
||||
val json = planContent(sessionId) ?: return null
|
||||
return runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
|
||||
return runCatching { compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId)) }
|
||||
.onFailure { log.error("freestyle: plan recompile for resume failed: {}", it.message) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/** Artifact ids validated earlier in the session (e.g. planning-phase `dod`) for the #264 needs seam. */
|
||||
private fun sessionArtifacts(sessionId: SessionId): Set<String> =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId?.value }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Grounds [graph] against recorded workspace facts and emits [PlanGroundingEvaluatedEvent].
|
||||
* Returns true if the plan was rejected (caller must stop before lock). Missing repo map/profile
|
||||
@@ -153,7 +161,10 @@ class FreestyleDriver(
|
||||
val repoMap = events.mapNotNull { it.payload as? RepoMapComputedEvent }.lastOrNull()
|
||||
val profile = events.mapNotNull { it.payload as? ProjectProfileBoundEvent }.lastOrNull()
|
||||
val paths = repoMap?.entries?.map { it.path }?.toSet().orEmpty()
|
||||
val result = PlanGrounder.ground(graph, paths, profile?.commands.orEmpty())
|
||||
// No RepoMapComputedEvent = no scan ran, so `paths` is unknown, not "empty workspace".
|
||||
// Tell the grounder not to prove paths absent from a set it never observed (the false
|
||||
// "apps/server/** doesn't exist" reject); the build-manifest check still runs.
|
||||
val result = PlanGrounder.ground(graph, paths, profile?.commands.orEmpty(), scanned = repoMap != null)
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
@@ -307,6 +318,28 @@ class FreestyleDriver(
|
||||
),
|
||||
),
|
||||
)
|
||||
// A rejected plan ends the session — but the last workflow verdict on record was the
|
||||
// planning phase's WorkflowCompleted, so the session read as SUCCESS (the "COMPLETED-lie").
|
||||
// Emit a session-terminal WorkflowFailed so the run's true outcome is on the log. stageId is
|
||||
// architect: the plan's producer and where a fix (or future return-to-architect loop) lands.
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = WorkflowFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId("architect"),
|
||||
reason = "execution plan rejected ($source): $reason",
|
||||
retryExhausted = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.correx.apps.server
|
||||
|
||||
import com.correx.core.config.ProjectConfig
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BootWorkspaceTest {
|
||||
@Test
|
||||
fun `workspace root clamps an outside configured working directory`() {
|
||||
val resolved = resolveBootWorkspace(
|
||||
explicitWorkspaceRoot = Path.of("/tmp/audition"),
|
||||
explicitWorkingDir = Path.of("/home/user/repo"),
|
||||
processWorkingDir = Path.of("/home/user/repo"),
|
||||
)
|
||||
|
||||
assertEquals(Path.of("/tmp/audition"), resolved.workspaceRoot)
|
||||
assertEquals(Path.of("/tmp/audition"), resolved.workingDir)
|
||||
assertTrue(resolved.workingDirWasClamped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `working directory inside workspace root remains intact`() {
|
||||
val resolved = resolveBootWorkspace(
|
||||
explicitWorkspaceRoot = Path.of("/tmp/audition"),
|
||||
explicitWorkingDir = Path.of("/tmp/audition/frontend"),
|
||||
processWorkingDir = Path.of("/home/user/repo"),
|
||||
)
|
||||
|
||||
assertEquals(Path.of("/tmp/audition"), resolved.workspaceRoot)
|
||||
assertEquals(Path.of("/tmp/audition/frontend"), resolved.workingDir)
|
||||
assertFalse(resolved.workingDirWasClamped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configured working directory supplies the root when workspace root is absent`() {
|
||||
val resolved = resolveBootWorkspace(
|
||||
explicitWorkspaceRoot = null,
|
||||
explicitWorkingDir = Path.of("/tmp/configured"),
|
||||
processWorkingDir = Path.of("/home/user/repo"),
|
||||
)
|
||||
|
||||
assertEquals(Path.of("/tmp/configured"), resolved.workspaceRoot)
|
||||
assertEquals(Path.of("/tmp/configured"), resolved.workingDir)
|
||||
assertFalse(resolved.workingDirWasClamped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `project memory root follows the authoritative workspace root`() {
|
||||
val configured = ProjectConfig(
|
||||
enabled = true,
|
||||
root = "/home/user/repo",
|
||||
)
|
||||
|
||||
val resolved = configured.boundToWorkspace(Path.of("/tmp/audition/../audition"))
|
||||
|
||||
assertEquals("/tmp/audition", resolved.root)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user