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
@@ -154,6 +154,12 @@ class FileEditTool(
) ->
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
// Anchor validity is checkable now, so reject a bad target BEFORE the approval gate
// fires — mirroring read/write's file-exists / read-before-write pre-checks. Without
// this the operator is prompted to approve an edit that then dies "Target not found"
// at execute time. Recoverable: the model corrects the anchor and retries.
operation == "replace" -> validateReplaceAnchor(path, pathString, request)
operation == "patch" && !request.parameters.containsKey("patch") ->
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
@@ -177,6 +183,29 @@ class FileEditTool(
private fun isPathAllowed(path: Path): Boolean =
PathJail.isContained(path, normalizedAllowedPaths)
/** Valid iff the replace target occurs exactly once. Same 0/>1 messaging as [replace] at execute. */
private fun validateReplaceAnchor(path: Path, pathString: String, request: ToolRequest): ValidationResult {
val target = request.parameters["target"] as? String ?: return ValidationResult.Valid
val content = Files.readString(path)
return when (val occurrences = content.split(target).size - 1) {
1 -> ValidationResult.Valid
0 -> ValidationResult.Invalid(targetNotFoundMessage(pathString, content, target))
else -> ValidationResult.Invalid(targetAmbiguousMessage(pathString, occurrences))
}
}
private fun targetNotFoundMessage(pathString: String, content: String, target: String): String =
buildString {
append("Target not found in $pathString")
nearestLine(content, target)?.let {
append(". Did you mean (whitespace/content may differ): $it ?")
}
}
private fun targetAmbiguousMessage(pathString: String, occurrences: Int): String =
"Target found $occurrences times in $pathString, expected exactly once — " +
"extend it with surrounding lines to make it unique."
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
when (e) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
@@ -270,18 +299,12 @@ class FileEditTool(
// always whitespace/content drift the model can correct from a nudge.
0 -> ToolResult.Failure(
invocationId = request.invocationId,
reason = buildString {
append("Target not found in $pathString")
nearestLine(currentContent, target)?.let {
append(". Did you mean (whitespace/content may differ): $it ?")
}
},
reason = targetNotFoundMessage(pathString, currentContent, target),
recoverable = true,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Target found $occurrences times in $pathString, expected exactly once — " +
"extend it with surrounding lines to make it unique.",
reason = targetAmbiguousMessage(pathString, occurrences),
recoverable = true,
)
}
@@ -95,6 +95,34 @@ class FileEditToolTest {
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
}
@Test
fun `validateRequest rejects a replace whose target is absent or ambiguous, before execute`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_anchor")
val filePath = tempDir.resolve("test.txt")
Files.writeString(filePath, "alpha\nbeta\nbeta\n")
val tool = FileEditTool(allowedPaths = setOf(tempDir))
val absent = tool.validateRequest(
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
"target" to "gamma", "replacement" to "x")),
)
assertTrue(absent is ValidationResult.Invalid)
assertTrue((absent as ValidationResult.Invalid).reason.contains("Target not found"))
val ambiguous = tool.validateRequest(
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
"target" to "beta", "replacement" to "x")),
)
assertTrue(ambiguous is ValidationResult.Invalid)
assertTrue((ambiguous as ValidationResult.Invalid).reason.contains("expected exactly once"))
val unique = tool.validateRequest(
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
"target" to "alpha", "replacement" to "x")),
)
assertEquals(ValidationResult.Valid, unique)
}
@Test
fun `validateRequest returns Invalid for missing parameters`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_edit_test")