From 46fbd597c3010f3dc8a42f6aa21ef5a7c41b1350 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 12 Jul 2026 12:48:30 +0400 Subject: [PATCH] fix(tools): atomic file_edit writes + bound the patch subprocess --- .../tools/filesystem/FileEditTool.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt index 37d3f3f4..cfe0d179 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt @@ -24,7 +24,6 @@ import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path import java.nio.file.Paths -import java.nio.file.StandardOpenOption @Suppress("TooManyFunctions") class FileEditTool( @@ -242,7 +241,9 @@ class FileEditTool( private fun append(path: Path, request: ToolRequest): ToolResult { val content = request.parameters["content"] as String - Files.writeString(path, content, StandardOpenOption.APPEND) + // Read-modify-atomic-swap instead of an in-place APPEND: a crash mid-append leaves the file + // whole (old content), never truncated. Same crash-safety FileWriteTool gets. + AtomicFileWriter.write(path, (Files.readString(path) + content).toByteArray(Charsets.UTF_8)) return ToolResult.Success( invocationId = request.invocationId, output = "Content appended to ${path.toAbsolutePath()}", @@ -258,7 +259,7 @@ class FileEditTool( return when (occurrences) { 1 -> { val newContent = currentContent.replace(target, replacement) - Files.writeString(path, newContent) + AtomicFileWriter.write(path, newContent.toByteArray(Charsets.UTF_8)) ToolResult.Success( invocationId = request.invocationId, output = "Target replaced in $pathString", @@ -338,7 +339,18 @@ class FileEditTool( return try { process.outputStream.use { it.write(patchContent.toByteArray()) } val output = process.inputStream.bufferedReader().use { it.readText() } - val exitCode = process.waitFor() + if (!process.waitFor(PATCH_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)) { + // A hung `patch` (e.g. prompting for a reject-file name it never gets) would block the + // stage forever. Kill it and let the model fall back to replace/file_write. + process.destroyForcibly() + return ToolResult.Failure( + invocationId = request.invocationId, + reason = "Patch timed out after ${PATCH_TIMEOUT_SECONDS}s. " + + "Consider using operation 'replace' (exact string swap) or file_write instead.", + recoverable = true, + ) + } + val exitCode = process.exitValue() if (exitCode == 0) { ToolResult.Success( @@ -389,5 +401,6 @@ class FileEditTool( const val MIN_ANCHOR = 3 const val MAX_CMP = 400 const val MIN_SIMILARITY = 0.5 + const val PATCH_TIMEOUT_SECONDS = 30L } }