← Back to blog

Hermes Agent File Writes Are Now Atomic - No Half-Written Files After Crashes

hermesfile-operationsatomicitycrash-safetydata-integrity
Hermes Agent File Writes Are Now Atomic - No Half-Written Files After Crashes

Before May 30, every write_file call in Hermes Agent streamed content directly into the target with cat > path. A crash, SIGKILL, OOM kill, or truncated pipe mid-write left the file half-written and corrupt. The same code path served patch operations, so both tools shared the flaw. PR #35252 replaced that with an atomic temp-file + rename pattern.

The implementation is a shell script inside file_operations.py that creates a hidden temp file in the same directory, streams content into it over stdin, then renames it over the target. Same-filesystem mv is atomic on every OS Hermes Agent runs on - POSIX, Linux, macOS, and docker/ssh/modal backends.

How it works

The new _atomic_write() method runs this pipeline:

  1. Create a temp file in the same directory as the target using mktemp (with a PID-stamped fallback for backends missing mktemp).
  2. Preserve the mode of the existing file by reading the octal permission bits with stat (GNU -c%a or BSD -f%Lp) and applying them to the temp file via chmod. This step is best-effort - a permission-copy failure does not abort the write.
  3. Stream content into the temp over stdin, avoiding the ARG_MAX limit that would constrain a command-line argument approach.
  4. Rename atomically with mv -f over the target. The same-directory constraint is load-bearing - mv across filesystem boundaries degrades to a non-atomic copy + unlink.
  5. Trap on EXIT removes the temp file on any failure (cat failure, mv failure, signal), but not after a successful mv since the temp no longer exists.

Content still rides stdin, so there is no file-size ceiling from argument length limits. Special characters - quotes, dollar signs, backticks, backslashes, unicode - round-trip correctly because they never touch shell interpretation.

At the shell level

The script the method constructs looks like this (variables are fully quoted in the actual implementation):

set -e
tmp=$(mktemp -p "$parent_dir" .hermes-tmp.XXXXXX)
trap 'rm -f "$tmp"' EXIT
# Preserve existing file mode (best-effort)
if [ -e "$target" ]; then
  m=$(stat -c%a "$target" 2>/dev/null || stat -f%Lp "$target" 2>/dev/null || true)
  [ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true
fi
cat > "$tmp"
mv -f "$tmp" "$target"
trap - EXIT

What changed

Metric Before After
Write interrupted mid-stream Target truncated/corrupt Original byte-intact, no temp leaked
Overwrite mechanism In-place rewrite Real rename (inode changes)
File mode across overwrite Reset to umask Preserved
File-tool test suite 194 tests 200 tests
Files changed 10 files, +805 / -31 lines

The core change is 70 lines of new code in _atomic_write(). The PR also added 76 lines of new tests in test_file_write_safety.py covering atomicity (inode change), mode preservation (0600/0640), crash-safety (read-only directory), temp-file cleanup, special character roundtrip, and patch routing. Two existing mock tests in test_file_operations.py were updated to key on stdin_data as the behavioral signal for a write operation instead of the literal cat > command, making the tests robust to the command-shape change.

Validation

The tests run against a real LocalEnvironment, not mocks. Key invariants verified:

  • Inode change on overwrite: A file that exists before write_file and is overwritten gets a new inode - confirming the mv is a real rename, not an in-place rewrite.
  • Mode preservation: A file with mode 0640 still has mode 0640 after being atomically overwritten. Without this, every write would silently widen or narrow permissions to the process umask.
  • Crash safety: When the parent directory is read-only (mode 0500), the temp file cannot be created. The write fails with an error, the original file survives byte-for-byte, and no .hermes-tmp file is left behind.
  • Special character roundtrip: Content containing single quotes, double quotes, dollar signs, backticks, backslashes, unicode, and newlines round-trips correctly.
  • Empty content: Writing an empty string produces a zero-byte file rather than erroring.
  • Patch inheritance: patch_replace routes through write_file, so every patch operation inherits atomicity automatically.

The Infomly team flagged the change on X:

Hermes Agent made file writes atomic. Every write used to stream straight to the target. A crash mid-write left a half-written, corrupt file. Now writes go to a hidden temp first, then rename over the target. Same-filesystem rename is atomic on every OS.

Why same-directory matters

The temp file is created in the same directory as the target for a specific reason: on POSIX systems, rename() is only guaranteed to be atomic when both paths reside on the same mounted filesystem. If the temp file were created in /tmp and the target lived on a different filesystem, the kernel would fall back to a copy + unlink - which is not atomic. A crash during the copy phase could still produce a corrupt target. Keeping the temp beside the target guarantees a real inode-level atomic rename.

What this means in practice

Agents crash. Processes get OOM-killed. SSH connections drop. Pipes break. Before this change, any of those failures during a write_file or patch operation left a half-written file that the agent could not distinguish from a complete one - the tool reports success after cat returns zero, and there is no post-write integrity check. Now, the file either contains the full new content (if mv succeeded) or the full original content (if anything failed before mv). There is no in-between state visible to a reader.

[^1]: Teknium. "PR #35252: fix(file-tools): make write_file/patch atomic (temp-file + rename)." NousResearch/hermes-agent. 2026-05-30. [^2]: Infomly. "Tweet: Hermes Agent made file writes atomic." X. 2026-05-30.

Termagotchi
_

Ryan Underdown

Autodidact. Rarely listens to advice.

Follow on X @catamarammed or GitHub @underdown