Summary
_stash_deferred_artifacts (engine.py, ~line 2338) moves a deferred story's spec into run_dir/deferred/<story_key>/<spec_name> via shutil.move. On the same filesystem that falls back to os.rename, which on Windows raises FileExistsError when the target already exists — i.e. when the same story defers a second time with the same spec filename. POSIX rename(2) silently overwrites, which is why this has never bitten on Linux/macOS.
This is another instance of the "POSIX-safe idiom, Windows-unsafe" class from #74 (H-list), surfaced while auditing PR #98 — it's the one rename-over site in the package that #98's atomic_replace sweep did not cover (the target is a possibly-existing file, not a fresh temp-rename).
Fix direction
Give the move overwrite semantics and the win32 retry in one shot: copy/write into a temp path inside the dest dir, then route through platform_util.atomic_replace (which wraps os.replace — overwriting — with the win32 sharing-violation retry from #98). Alternatively os.replace directly if the source is same-filesystem-guaranteed.
Notes
- The stash is a write-only forensic artifact (no reader recomposes the path), so the blast radius is the defer flow crashing mid-run on Windows, not data corruption.
- The two stdlib-only hook scripts'
os.replace calls (data/bmad_loop_hook.py, data/bmad_loop_probe_hook.py) were also checked and are fine as-is: time_ns-unique target names, never concurrently read, and they can't import the package by design.
Refs: #74, #98
Summary
_stash_deferred_artifacts(engine.py, ~line 2338) moves a deferred story's spec intorun_dir/deferred/<story_key>/<spec_name>viashutil.move. On the same filesystem that falls back toos.rename, which on Windows raisesFileExistsErrorwhen the target already exists — i.e. when the same story defers a second time with the same spec filename. POSIXrename(2)silently overwrites, which is why this has never bitten on Linux/macOS.This is another instance of the "POSIX-safe idiom, Windows-unsafe" class from #74 (H-list), surfaced while auditing PR #98 — it's the one rename-over site in the package that #98's
atomic_replacesweep did not cover (the target is a possibly-existing file, not a fresh temp-rename).Fix direction
Give the move overwrite semantics and the win32 retry in one shot: copy/write into a temp path inside the dest dir, then route through
platform_util.atomic_replace(which wrapsos.replace— overwriting — with the win32 sharing-violation retry from #98). Alternativelyos.replacedirectly if the source is same-filesystem-guaranteed.Notes
os.replacecalls (data/bmad_loop_hook.py,data/bmad_loop_probe_hook.py) were also checked and are fine as-is: time_ns-unique target names, never concurrently read, and they can't import the package by design.Refs: #74, #98