From 0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 23 Jul 2026 23:55:15 -0500 Subject: fix(inbox-send): write handoffs atomically so a failure leaves no phantom inbox-send wrote straight to the destination path, and write_text truncates on open, so any mid-write failure left a zero-byte .org in another project's inbox. inbox-status counted that phantom as a pending handoff and blocked a turn in the receiving project over a file with no content and no sender. Both send paths now write to a temp sibling and os.replace it into place, so the inbox only ever sees a complete file. A caught failure removes the temp and re-raises, leaving no debris. encoding is pinned to utf-8 on the write and on the roots-config read, which closes the locale-dependent failure that first surfaced this. inbox-status also skips the .inbox-send-* temp, so the brief window before the rename can't read as pending either. The atomicity is only complete once the consumer ignores the in-flight file. --- .ai/scripts/inbox-send.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) (limited to '.ai/scripts/inbox-send.py') diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py index 1ebb636..5787a84 100755 --- a/.ai/scripts/inbox-send.py +++ b/.ai/scripts/inbox-send.py @@ -31,6 +31,7 @@ import os import re import shutil import sys +import tempfile from datetime import datetime from pathlib import Path @@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]: config = Path.home() / ".claude" / "inbox-roots.txt" if config.is_file(): paths: list[Path] = [] - for line in config.read_text().splitlines(): + for line in config.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#"): paths.append(Path(line).expanduser()) @@ -194,6 +195,39 @@ def uniquify(dest: Path) -> Path: n += 1 +def _atomic_write(dest: Path, writer) -> None: + """Write to a temp file in dest's directory, then rename it into place. + + dest is another project's inbox/, and a direct write truncates the target + on open, so any mid-write failure (a full disk, an encoding error, an + interrupted process) leaves a zero-byte .org there. inbox-status counts + that phantom as a pending handoff and blocks a turn in the receiving + project over a file with no content and no sender (2026-07-23). Writing to + a temp sibling and os.replace-ing means the inbox only ever sees a complete + file. os.replace is atomic within one filesystem, and the temp sits in the + same directory as dest, so it is. + + `writer` receives the open temp path and fills it. On any failure the temp + is removed and the error re-raised, so a caught error never leaves debris. + """ + fd, tmp = tempfile.mkstemp( + dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix + ) + os.close(fd) + tmp_path = Path(tmp) + # mkstemp creates the temp 0600; give the delivered file the umask-default + # mode the old direct write produced, so inbox files stay readable as before. + umask = os.umask(0) + os.umask(umask) + os.chmod(tmp_path, 0o666 & ~umask) + try: + writer(tmp_path) + os.replace(tmp_path, dest) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + def send_text( target_inbox: Path, message: str, @@ -209,7 +243,8 @@ def send_text( raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" dest = uniquify(target_inbox / filename) - dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) + body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT)) + _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8")) return dest @@ -229,7 +264,7 @@ def send_file( ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" dest = uniquify(target_inbox / filename) - shutil.copy2(src_path, dest) + _atomic_write(dest, lambda p: shutil.copyfile(src_path, p)) return dest -- cgit v1.2.3