1+ """Best-effort prompt CAS with per-file atomic replacement.
2+
3+ Hash checks narrow concurrency windows but cannot make a multi-file operation
4+ transactional. A non-cooperating writer can still race after the last check.
5+ """
6+
17from __future__ import annotations
28
39import hashlib
@@ -62,8 +68,13 @@ def _current_hashes(snapshot: PromptSnapshot) -> dict[str, str]:
6268 return {name : _hash_bytes (prompt_file .path .read_bytes ()) for name , prompt_file in snapshot .files .items ()}
6369
6470
65- def _atomic_replace_bytes (path : Path , content : bytes ) -> None :
66- """Replace one file atomically after durably flushing a same-directory temp."""
71+ def _atomic_replace_bytes (
72+ path : Path ,
73+ content : bytes ,
74+ * ,
75+ expected_sha256 : str | None = None ,
76+ ) -> None :
77+ """Replace one file atomically if its last observed hash is still expected."""
6778
6879 fd , temp_name = tempfile .mkstemp (
6980 dir = path .parent ,
@@ -78,6 +89,10 @@ def _atomic_replace_bytes(path: Path, content: bytes) -> None:
7889 temp_file .write (content )
7990 temp_file .flush ()
8091 os .fsync (temp_file .fileno ())
92+ if expected_sha256 is not None :
93+ current_sha256 = _hash_bytes (path .read_bytes ())
94+ if current_sha256 != expected_sha256 :
95+ raise ConcurrentPromptUpdateError (f"source prompt file changed before replace: { path } " )
8196 os .replace (temp_path , path )
8297 finally :
8398 active_exception = sys .exc_info ()[0 ] is not None
@@ -98,13 +113,31 @@ def _atomic_replace_bytes(path: Path, content: bytes) -> None:
98113 raise cleanup_error
99114
100115
101- def _restore_snapshot (snapshot : PromptSnapshot ) -> list [str ]:
116+ def _restore_snapshot (
117+ snapshot : PromptSnapshot ,
118+ written_hashes : dict [str , str ],
119+ ) -> list [str ]:
120+ """Conditionally restore only files this operation successfully replaced."""
121+
102122 failures : list [str ] = []
103- for name , prompt_file in snapshot .files .items ():
123+ for name , candidate_hash in written_hashes .items ():
124+ prompt_file = snapshot .files [name ]
104125 try :
105- _atomic_replace_bytes (prompt_file .path , prompt_file . content )
126+ current_hash = _hash_bytes (prompt_file .path . read_bytes () )
106127 except OSError as error :
107- failures .append (f"{ name } : { error } " )
128+ failures .append (f"{ name } : restore precondition failed: { error } " )
129+ continue
130+ if current_hash != candidate_hash :
131+ failures .append (f"{ name } : restore conflict; current hash changed from candidate" )
132+ continue
133+ try :
134+ _atomic_replace_bytes (
135+ prompt_file .path ,
136+ prompt_file .content ,
137+ expected_sha256 = candidate_hash ,
138+ )
139+ except (OSError , ConcurrentPromptUpdateError ) as error :
140+ failures .append (f"{ name } : restore failed: { error } " )
108141 return failures
109142
110143
@@ -143,15 +176,39 @@ def temporary_prompt_bundle(
143176 snapshot : PromptSnapshot ,
144177 prompts : dict [str , str ],
145178) -> Iterator [None ]:
146- """Temporarily install candidate prompts and always restore the snapshot ."""
179+ """Install candidates temporarily using best-effort CAS and conditional restore ."""
147180
148181 encoded_prompts = _encode_prompt_bundle (snapshot , prompts )
182+ expected_hashes = snapshot .hashes ()
183+ current_hashes = _current_hashes (snapshot )
184+ if current_hashes != expected_hashes :
185+ changed = [name for name , expected_hash in expected_hashes .items () if current_hashes [name ] != expected_hash ]
186+ raise ConcurrentPromptUpdateError (f"source prompt files changed since snapshot: { ', ' .join (changed )} " )
187+
188+ candidate_hashes = {name : _hash_bytes (content ) for name , content in encoded_prompts .items ()}
189+ written_hashes : dict [str , str ] = {}
149190 try :
150191 for name , prompt_file in snapshot .files .items ():
151- _atomic_replace_bytes (prompt_file .path , encoded_prompts [name ])
192+ _atomic_replace_bytes (
193+ prompt_file .path ,
194+ encoded_prompts [name ],
195+ expected_sha256 = expected_hashes [name ],
196+ )
197+ written_hashes [name ] = candidate_hashes [name ]
152198 yield
153- finally :
154- failures = _restore_snapshot (snapshot )
199+ except BaseException as primary_error :
200+ failures = _restore_snapshot (snapshot , written_hashes )
201+ restoration_error = _restoration_error (snapshot , failures )
202+ if restoration_error is not None :
203+ diagnostic = f"failed to restore prompt snapshot: { restoration_error } "
204+ add_note = getattr (primary_error , "add_note" , None )
205+ if add_note is not None :
206+ add_note (diagnostic )
207+ else :
208+ raise primary_error from RuntimeError (diagnostic )
209+ raise
210+ else :
211+ failures = _restore_snapshot (snapshot , written_hashes )
155212 restoration_error = _restoration_error (snapshot , failures )
156213 if restoration_error is not None :
157214 raise RuntimeError (f"failed to restore prompt snapshot: { restoration_error } " )
@@ -161,7 +218,7 @@ def commit_prompt_bundle(
161218 snapshot : PromptSnapshot ,
162219 prompts : dict [str , str ],
163220) -> WritebackResult :
164- """Apply a complete prompt bundle with CAS and compensating rollback."""
221+ """Apply a bundle with best-effort CAS and conditional compensating rollback."""
165222
166223 before_hashes = _current_hashes (snapshot )
167224 expected_hashes = snapshot .hashes ()
@@ -170,20 +227,37 @@ def commit_prompt_bundle(
170227 raise ConcurrentPromptUpdateError (f"source prompt files changed since snapshot: { ', ' .join (changed )} " )
171228
172229 encoded_prompts = _encode_prompt_bundle (snapshot , prompts )
230+ candidate_hashes = {name : _hash_bytes (content ) for name , content in encoded_prompts .items ()}
231+ written_hashes : dict [str , str ] = {}
173232 try :
174233 for name , prompt_file in snapshot .files .items ():
175- _atomic_replace_bytes (prompt_file .path , encoded_prompts [name ])
234+ _atomic_replace_bytes (
235+ prompt_file .path ,
236+ encoded_prompts [name ],
237+ expected_sha256 = expected_hashes [name ],
238+ )
239+ written_hashes [name ] = candidate_hashes [name ]
176240 applied_hashes = _current_hashes (snapshot )
177- except OSError as error :
178- rollback_failures = _restore_snapshot (snapshot )
241+ candidate_mismatches = [
242+ name for name , candidate_hash in candidate_hashes .items () if applied_hashes [name ] != candidate_hash
243+ ]
244+ if candidate_mismatches :
245+ raise ConcurrentPromptUpdateError (
246+ "candidate prompt files changed before final verification: " f"{ ', ' .join (candidate_mismatches )} "
247+ )
248+ except (OSError , ConcurrentPromptUpdateError ) as error :
249+ if isinstance (error , ConcurrentPromptUpdateError ) and not written_hashes :
250+ raise
251+
252+ rollback_failures = _restore_snapshot (snapshot , written_hashes )
179253 try :
180254 after_hashes = _current_hashes (snapshot )
181255 except OSError as hash_error :
182256 after_hashes = {}
183257 rollback_failures .append (f"hash verification: { hash_error } " )
184258 else :
185259 rollback_failures .extend (
186- f"{ name } : restored hash differs from snapshot"
260+ f"{ name } : final hash differs from snapshot"
187261 for name , expected_hash in expected_hashes .items ()
188262 if after_hashes [name ] != expected_hash
189263 )
@@ -192,7 +266,7 @@ def commit_prompt_bundle(
192266 if rollback_failures :
193267 error_message += f"; rollback failures: { '; ' .join (rollback_failures )} "
194268 return WritebackResult (
195- status = "rollback_failed " if rollback_failures else "rolled_back " ,
269+ status = "rolled_back " if after_hashes == expected_hashes else "rollback_failed " ,
196270 before_hashes = before_hashes ,
197271 after_hashes = after_hashes ,
198272 error = error_message ,
0 commit comments