fix(file-tools): make write_file/patch atomic (temp-file + rename) (#35252)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * fix(file-tools): make write_file/patch atomic (temp-file + rename) write_file streamed content straight into the target via `cat > path`, so a crash, SIGKILL, or truncated pipe mid-write left the file half-written and corrupt. patch_replace routes through write_file, so it shared the flaw. Now writes stream into a temp file in the SAME directory and `mv` it over the target — a real same-filesystem rename, which is atomic on POSIX and on every terminal backend (local/docker/ssh/modal). A failed write leaves the original byte-intact and leaks no temp file. The existing file's mode is preserved across the swap (stat + chmod, GNU/BSD), and content still rides stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path. Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering inode-change-on-overwrite, mode preservation, failed-write-leaves-original, no-temp-leak, special chars, and patch routing. Updated two mocks in test_file_operations.py that keyed on the literal `cat >` write command to key on the stdin_data behavioral signal instead. 200 file-tool tests green.
This commit is contained in:
@@ -638,12 +638,14 @@ class TestPatchReplacePostWriteVerification:
|
||||
state = {"content": "hello world\n"}
|
||||
|
||||
def side_effect(command, stdin_data=None, **kwargs):
|
||||
# Write is `cat > path` — detect by the `>` redirect, NOT just `cat `
|
||||
if command.startswith("cat >"):
|
||||
if stdin_data is not None:
|
||||
state["content"] = stdin_data
|
||||
# A write is the only call that pipes content over stdin — key
|
||||
# on that behavioral signal rather than the exact write command,
|
||||
# which is an atomic temp-file + mv script (`set -e; ... mv ...`),
|
||||
# not a bare `cat > path`.
|
||||
if stdin_data is not None:
|
||||
state["content"] = stdin_data
|
||||
return {"output": "", "returncode": 0}
|
||||
if command.startswith("cat "): # read
|
||||
if command.startswith("cat "): # read / verify
|
||||
return {"output": state["content"], "returncode": 0}
|
||||
if command.startswith("mkdir "):
|
||||
return {"output": "", "returncode": 0}
|
||||
@@ -664,9 +666,8 @@ class TestPatchReplacePostWriteVerification:
|
||||
state = {"content": "hello world\n"}
|
||||
|
||||
def side_effect(command, stdin_data=None, **kwargs):
|
||||
if command.startswith("cat >"): # write
|
||||
if stdin_data is not None:
|
||||
state["content"] = stdin_data
|
||||
if stdin_data is not None: # write (atomic temp-file + mv script)
|
||||
state["content"] = stdin_data
|
||||
return {"output": "", "returncode": 0}
|
||||
if command.startswith("cat "): # read
|
||||
call_count["cat"] += 1
|
||||
|
||||
Reference in New Issue
Block a user