gabriel / muse public
test_integrity_I2_fsync.py python
1,771 lines 70.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """I-2: fsync before atomic rename in ALL durable write paths.
2
3 Covers every write primitive in the Muse durability chain:
4
5 Tier 0 — Primitive helpers
6 * write_text_atomic — the canonical text-file atomic helper
7 * _write_msgpack_atomic — the canonical msgpack helper
8
9 Tier 1 — HEAD + branch refs (catastrophic if corrupt)
10 * write_head_branch (store.py)
11 * write_head_commit (store.py)
12 * write_branch_ref (store.py — used by commit, merge, checkout, pull,
13 revert, reset, cherry_pick, update_ref, transport,
14 rebase, bundle)
15
16 Tier 2 — VCS state files
17 * write_merge_state (merge_engine.py)
18 * save_rebase_state (rebase.py)
19 * create_reservation, create_intent (coordination.py)
20 * OpLog checkpoint (op_log.py)
21
22 Tier 3 — Config files
23 * config.py write_config_value / set_remote (via write_text_atomic)
24
25 Tier 4 — Large-blob write paths (shutil.copy2-based)
26 * write_object_from_path — uses _fsync_fd (fd-based) before os.replace
27 * restore_object — uses _fsync_path (path-based) before os.replace
28
29 Each tier is verified for:
30 1. mkstemp unique temp names (no fixed .tmp collisions).
31 2. fsync before os.replace — ordering enforced.
32 3. No orphan temp files after success or simulated crash.
33 4. Concurrent write safety — independent results, no cross-corruption.
34 5. Correct final on-disk content.
35
36 Additional coverage (gap audit):
37 6. Page-cache non-flush defense-in-depth (I-1 is the backstop for I-2).
38 7. Mid-write fh.write failure — orphan cleaned up.
39 8. Same object_id written from N threads simultaneously — idempotency holds.
40 9. 10 000 sequential commits — store clean throughout.
41 10. SIGKILL crash safety — multiprocessing kill leaves no orphans, store consistent.
42 11. Performance benchmark — 4 KiB msgpack fsync write < 5 ms.
43
44 Regression — any write path that bypasses write_text_atomic is caught here.
45 """
46 from __future__ import annotations
47
48 import hashlib
49 import os
50 import pathlib
51 import tempfile
52 import threading
53 from unittest.mock import patch
54
55
56 def _sigkill_writer_worker(root: pathlib.Path, count: int) -> None:
57 """Write objects in a tight loop until killed.
58
59 Defined at module level so it is picklable under the ``"spawn"``
60 multiprocessing context (closures defined inside test methods are not
61 picklable and therefore incompatible with ``"spawn"``).
62 """
63 import time as _time
64
65 import hashlib as _hl
66
67 from muse.core.object_store import write_object as _wo
68
69 for i in range(count):
70 payload = f"crash-worker-object-{i}".encode()
71 obj_id = _hl.sha256(payload).hexdigest()
72 try:
73 _wo(root, obj_id, payload)
74 except Exception:
75 pass
76 _time.sleep(0.0001)
77
78 import pytest
79
80 import json
81 import muse.core.rebase
82
83
84 def _corrupt_file(p: pathlib.Path, new_content: bytes) -> None:
85 """Overwrite *p* temporarily lifting the 0o444 guard.
86
87 Object files are written with mode 0o444. Tests that simulate disk
88 corruption must temporarily grant write permission.
89 """
90 os.chmod(p, 0o644)
91 try:
92 p.write_bytes(new_content)
93 finally:
94 os.chmod(p, 0o444)
95
96 from muse.core.coordination import Reservation
97 from muse.core.rebase import RebaseState
98 from muse.core.object_store import (
99 _fsync_fd,
100 write_object,
101 write_object_from_path,
102 restore_object,
103 read_object,
104 object_path,
105 )
106 from muse.core.snapshot import compute_commit_id
107 from muse.core.store import (
108 CommitRecord,
109 write_branch_ref,
110 write_commit,
111 write_head_branch,
112 write_head_commit,
113 write_text_atomic,
114 read_commit,
115 )
116 import datetime
117
118
119 # ---------------------------------------------------------------------------
120 # Helpers
121 # ---------------------------------------------------------------------------
122
123 def _repo(tmp_path: pathlib.Path) -> pathlib.Path:
124 (tmp_path / ".muse").mkdir()
125 (tmp_path / ".muse" / "commits").mkdir()
126 (tmp_path / ".muse" / "snapshots").mkdir()
127 return tmp_path
128
129
130 def _oid(data: bytes) -> str:
131 return hashlib.sha256(data).hexdigest()
132
133
134 def _commit(idx: int = 0) -> CommitRecord:
135 sid = hashlib.sha256(f"snap-{idx}".encode()).hexdigest()
136 message = f"commit {idx}"
137 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
138 cid = compute_commit_id([], sid, message, committed_at.isoformat())
139 return CommitRecord(
140 commit_id=cid,
141 repo_id="test-repo",
142 branch="main",
143 snapshot_id=sid,
144 message=message,
145 committed_at=committed_at,
146 author="tester",
147 parent_commit_id=None,
148 parent2_commit_id=None,
149 )
150
151
152 def _tmp_files(directory: pathlib.Path) -> list[pathlib.Path]:
153 """Return all temp/orphan files in *directory* (recursively)."""
154 result: list[pathlib.Path] = []
155 for p in directory.rglob("*"):
156 name = p.name
157 if name.startswith(".obj-tmp-") or name.startswith(".muse-tmp-") or name.startswith(".restore-tmp-"):
158 result.append(p)
159 return result
160
161
162 # ---------------------------------------------------------------------------
163 # Unit: fsync is called before os.replace in write_object
164 # ---------------------------------------------------------------------------
165
166 class TestFsyncCalledBeforeReplace:
167 def test_write_object_calls_fsync_before_replace(self, tmp_path: pathlib.Path) -> None:
168 """_fsync_fd must be called before os.replace in write_object.
169
170 Patches _fsync_fd (the platform abstraction) rather than os.fsync
171 directly, because on macOS _fsync_fd uses fcntl(F_BARRIERFSYNC) and
172 returns before ever reaching os.fsync.
173 """
174 repo = _repo(tmp_path)
175 data = b"fsync ordering test"
176 oid = _oid(data)
177
178 call_order: list[str] = []
179 real_fsync_fd = _fsync_fd
180 real_replace = os.replace
181
182 def tracking_fsync_fd(fd: int) -> None:
183 call_order.append("fsync")
184 real_fsync_fd(fd)
185
186 def tracking_replace(src: str | bytes | os.PathLike[str], dst: str | bytes | os.PathLike[str]) -> None:
187 call_order.append("replace")
188 real_replace(src, dst)
189
190 with patch("muse.core.object_store._fsync_fd", side_effect=tracking_fsync_fd), \
191 patch("muse.core.object_store.os.replace", side_effect=tracking_replace):
192 write_object(repo, oid, data)
193
194 assert "fsync" in call_order, "_fsync_fd was never called"
195 assert "replace" in call_order, "replace was never called"
196 fsync_pos = next(i for i, c in enumerate(call_order) if c == "fsync")
197 replace_pos = next(i for i, c in enumerate(call_order) if c == "replace")
198 assert fsync_pos < replace_pos, (
199 f"_fsync_fd (pos {fsync_pos}) must happen before replace (pos {replace_pos})"
200 )
201
202 def test_write_commit_calls_fsync_before_replace(self, tmp_path: pathlib.Path) -> None:
203 """A durability flush must be called before tmp.replace in _write_msgpack_atomic.
204
205 On macOS, _write_msgpack_atomic uses fcntl(F_BARRIERFSYNC) and never
206 reaches os.fsync directly. We track both paths so the test is
207 platform-agnostic.
208 """
209 repo = _repo(tmp_path)
210 c = _commit(0)
211
212 call_order: list[str] = []
213 real_fsync = os.fsync
214 import fcntl as _fcntl
215 real_fcntl = _fcntl.fcntl
216
217 def tracking_fsync(fd: int) -> None:
218 call_order.append("fsync")
219 real_fsync(fd)
220
221 def tracking_fcntl(fd: int, cmd: int, *args: int) -> int:
222 if cmd == 85: # F_BARRIERFSYNC
223 call_order.append("fsync")
224 return real_fcntl(fd, cmd, *args)
225
226 with patch("muse.core.store.os.fsync", side_effect=tracking_fsync), \
227 patch("muse.core.store.fcntl.fcntl", side_effect=tracking_fcntl):
228 write_commit(repo, c)
229
230 assert "fsync" in call_order, "no durability flush called during write_commit"
231
232 def test_fsync_failure_is_non_fatal(self, tmp_path: pathlib.Path) -> None:
233 """A failing fsync (virtual fs) must not prevent the write from completing."""
234 repo = _repo(tmp_path)
235 data = b"fsync fails gracefully"
236 oid = _oid(data)
237
238 with patch("muse.core.object_store.os.fsync", side_effect=OSError("fsync not supported")):
239 result = write_object(repo, oid, data)
240
241 assert result is True
242 assert read_object(repo, oid) == data
243
244 def test_fsync_failure_in_store_is_non_fatal(self, tmp_path: pathlib.Path) -> None:
245 """A failing fsync in _write_msgpack_atomic must not abort the commit write."""
246 repo = _repo(tmp_path)
247 c = _commit(1)
248
249 with patch("muse.core.store.os.fsync", side_effect=OSError("not supported")):
250 write_commit(repo, c)
251
252 assert read_commit(repo, c.commit_id) is not None
253
254
255 # ---------------------------------------------------------------------------
256 # Unit: unique temp file names (mkstemp, not fixed .tmp)
257 # ---------------------------------------------------------------------------
258
259 class TestUniqueTempNames:
260 def test_write_object_uses_mkstemp(self, tmp_path: pathlib.Path) -> None:
261 """write_object must use tempfile.mkstemp, not path.with_suffix('.tmp')."""
262 repo = _repo(tmp_path)
263 data = b"unique temp name check"
264 oid = _oid(data)
265
266 mkstemp_called = [False]
267 real_mkstemp = tempfile.mkstemp
268
269 def tracking_mkstemp(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
270 mkstemp_called[0] = True
271 return real_mkstemp(dir=dir, prefix=prefix)
272
273 with patch("muse.core.object_store.tempfile.mkstemp", side_effect=tracking_mkstemp):
274 write_object(repo, oid, data)
275
276 assert mkstemp_called[0], "tempfile.mkstemp was not called — fixed .tmp name may be in use"
277
278 def test_write_commit_uses_mkstemp(self, tmp_path: pathlib.Path) -> None:
279 """_write_msgpack_atomic must use tempfile.mkstemp."""
280 repo = _repo(tmp_path)
281 c = _commit(2)
282
283 mkstemp_called = [False]
284 real_mkstemp = tempfile.mkstemp
285
286 def tracking_mkstemp(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
287 mkstemp_called[0] = True
288 return real_mkstemp(dir=dir, prefix=prefix)
289
290 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking_mkstemp):
291 write_commit(repo, c)
292
293 assert mkstemp_called[0], "tempfile.mkstemp was not called in _write_msgpack_atomic"
294
295 def test_no_fixed_tmp_suffix_after_write(self, tmp_path: pathlib.Path) -> None:
296 """After a successful write, no .tmp file must remain."""
297 repo = _repo(tmp_path)
298 c = _commit(3)
299 write_commit(repo, c)
300
301 fixed_tmps = list((tmp_path / ".muse" / "commits").glob("*.tmp"))
302 assert fixed_tmps == [], f"Fixed .tmp files left behind: {fixed_tmps}"
303
304
305 # ---------------------------------------------------------------------------
306 # Integration: no orphan temps after successful writes
307 # ---------------------------------------------------------------------------
308
309 class TestNoOrphanTemps:
310 def test_no_orphan_after_write_object(self, tmp_path: pathlib.Path) -> None:
311 repo = _repo(tmp_path)
312 data = b"clean write"
313 oid = _oid(data)
314 write_object(repo, oid, data)
315 assert _tmp_files(tmp_path) == []
316
317 def test_no_orphan_after_write_object_from_path(self, tmp_path: pathlib.Path) -> None:
318 repo = _repo(tmp_path)
319 src = tmp_path / "src.bin"
320 data = b"from path write"
321 src.write_bytes(data)
322 oid = _oid(data)
323 write_object_from_path(repo, oid, src)
324 assert _tmp_files(tmp_path) == []
325
326 def test_no_orphan_after_restore_object(self, tmp_path: pathlib.Path) -> None:
327 repo = _repo(tmp_path)
328 data = b"restore write"
329 oid = _oid(data)
330 write_object(repo, oid, data)
331 dest = tmp_path / "restored.bin"
332 restore_object(repo, oid, dest)
333 assert _tmp_files(tmp_path) == []
334
335 def test_no_orphan_after_write_commit(self, tmp_path: pathlib.Path) -> None:
336 repo = _repo(tmp_path)
337 write_commit(repo, _commit(4))
338 assert _tmp_files(tmp_path) == []
339
340 def test_no_orphan_after_simulated_replace_failure(self, tmp_path: pathlib.Path) -> None:
341 """When os.replace raises, the temp file must be cleaned up."""
342 repo = _repo(tmp_path)
343 data = b"replace will fail"
344 oid = _oid(data)
345
346 with pytest.raises(OSError):
347 with patch("muse.core.object_store.os.replace", side_effect=OSError("disk full")):
348 write_object(repo, oid, data)
349
350 assert _tmp_files(tmp_path) == [], "Temp file not cleaned up after replace failure"
351
352 def test_no_orphan_in_store_after_replace_failure(self, tmp_path: pathlib.Path) -> None:
353 """When tmp.replace raises in _write_msgpack_atomic, temp is cleaned up."""
354 repo = _repo(tmp_path)
355 c = _commit(5)
356
357 with pytest.raises(OSError):
358 with patch("muse.core.store.os.fdopen") as mock_fdopen:
359 mock_fh = mock_fdopen.return_value.__enter__.return_value
360 mock_fh.write.return_value = None
361 mock_fh.flush.return_value = None
362 mock_fh.fileno.return_value = -1
363 with patch("muse.core.store.os.fsync"), \
364 patch("muse.core.store.fcntl.fcntl"):
365 with patch("pathlib.Path.replace", side_effect=OSError("disk full")):
366 write_commit(repo, c)
367
368 assert _tmp_files(tmp_path) == [], "Temp file not cleaned up after commit replace failure"
369
370
371 # ---------------------------------------------------------------------------
372 # Stress: 200 concurrent writers to the same shard
373 # ---------------------------------------------------------------------------
374
375 class TestConcurrentWriters:
376 @pytest.mark.slow
377 def test_200_concurrent_object_writes_no_corruption(self, tmp_path: pathlib.Path) -> None:
378 """200 threads writing distinct objects concurrently must all land correctly."""
379 repo = _repo(tmp_path)
380 payloads = [f"concurrent-object-{i}".encode() for i in range(200)]
381 oids = [_oid(p) for p in payloads]
382 errors: list[str] = []
383
384 def writer(data: bytes, oid: str) -> None:
385 try:
386 write_object(repo, oid, data)
387 result = read_object(repo, oid)
388 if result != data:
389 errors.append(f"Mismatch for {oid[:8]}: got {repr(result)[:20]}")
390 except Exception as exc:
391 errors.append(f"Exception for {oid[:8]}: {exc}")
392
393 threads = [
394 threading.Thread(target=writer, args=(p, o))
395 for p, o in zip(payloads, oids)
396 ]
397 for t in threads:
398 t.start()
399 for t in threads:
400 t.join()
401
402 assert errors == [], f"Concurrent write errors:\n" + "\n".join(errors)
403 # Every object must be present and correct
404 for data, oid in zip(payloads, oids):
405 assert read_object(repo, oid) == data
406
407 def test_100_concurrent_commit_writes_no_corruption(self, tmp_path: pathlib.Path) -> None:
408 """100 threads writing distinct commits concurrently must all land correctly."""
409 repo = _repo(tmp_path)
410 commits = [_commit(i) for i in range(100)]
411 errors: list[str] = []
412
413 def writer(c: CommitRecord) -> None:
414 try:
415 write_commit(repo, c)
416 result = read_commit(repo, c.commit_id)
417 if result is None:
418 errors.append(f"Commit {c.commit_id[:8]} not found after write")
419 elif result.message != c.message:
420 errors.append(f"Commit {c.commit_id[:8]} message corrupted")
421 except Exception as exc:
422 errors.append(f"Exception for {c.commit_id[:8]}: {exc}")
423
424 threads = [threading.Thread(target=writer, args=(c,)) for c in commits]
425 for t in threads:
426 t.start()
427 for t in threads:
428 t.join()
429
430 assert errors == [], "Concurrent commit write errors:\n" + "\n".join(errors)
431
432 def test_no_orphan_temps_after_concurrent_writes(self, tmp_path: pathlib.Path) -> None:
433 """No temp files must remain after concurrent writes complete."""
434 repo = _repo(tmp_path)
435 payloads = [f"orphan-check-{i}".encode() for i in range(50)]
436
437 threads = [
438 threading.Thread(target=write_object, args=(repo, _oid(p), p))
439 for p in payloads
440 ]
441 for t in threads:
442 t.start()
443 for t in threads:
444 t.join()
445
446 assert _tmp_files(tmp_path) == []
447
448
449 # ---------------------------------------------------------------------------
450 # Tier 0: write_text_atomic — the primitive all text-state writes funnel through
451 # ---------------------------------------------------------------------------
452
453 class TestWriteTextAtomic:
454 """Unit tests for the write_text_atomic primitive."""
455
456 def test_writes_correct_content(self, tmp_path: pathlib.Path) -> None:
457 path = tmp_path / "state.txt"
458 write_text_atomic(path, "hello world\n")
459 assert path.read_text() == "hello world\n"
460
461 def test_creates_parent_dirs(self, tmp_path: pathlib.Path) -> None:
462 path = tmp_path / "a" / "b" / "c" / "state.txt"
463 write_text_atomic(path, "deep")
464 assert path.read_text() == "deep"
465
466 def test_uses_mkstemp_not_fixed_tmp(self, tmp_path: pathlib.Path) -> None:
467 """write_text_atomic must use tempfile.mkstemp, not path.with_suffix('.tmp')."""
468 path = tmp_path / "ref"
469 called = [False]
470 real_mkstemp = tempfile.mkstemp
471
472 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
473 called[0] = True
474 return real_mkstemp(dir=dir, prefix=prefix)
475
476 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
477 write_text_atomic(path, "abc")
478
479 assert called[0], "write_text_atomic did not call tempfile.mkstemp"
480
481 def test_fsync_called_before_replace(self, tmp_path: pathlib.Path) -> None:
482 """os.fsync must be called before os.replace in write_text_atomic."""
483 path = tmp_path / "ref"
484 call_order: list[str] = []
485 real_fsync = os.fsync
486 real_replace = os.replace
487
488 def t_fsync(fd: int) -> None:
489 call_order.append("fsync")
490 real_fsync(fd)
491
492 def t_replace(src: str | bytes | os.PathLike[str], dst: str | bytes | os.PathLike[str]) -> None:
493 call_order.append("replace")
494 real_replace(src, dst)
495
496 with patch("muse.core.store.os.fsync", side_effect=t_fsync), \
497 patch("muse.core.store.os.replace", side_effect=t_replace):
498 write_text_atomic(path, "content")
499
500 fsync_pos = next((i for i, c in enumerate(call_order) if c == "fsync"), None)
501 replace_pos = next((i for i, c in enumerate(call_order) if c == "replace"), None)
502 assert fsync_pos is not None, "fsync never called"
503 assert replace_pos is not None, "replace never called"
504 assert fsync_pos < replace_pos, "fsync must happen before replace"
505
506 def test_fsync_failure_is_non_fatal(self, tmp_path: pathlib.Path) -> None:
507 """A failing fsync (virtual fs) must not prevent the write from completing."""
508 path = tmp_path / "ref"
509 with patch("muse.core.store.os.fsync", side_effect=OSError("not supported")):
510 write_text_atomic(path, "durable despite fsync failure")
511 assert path.read_text() == "durable despite fsync failure"
512
513 def test_no_orphan_after_success(self, tmp_path: pathlib.Path) -> None:
514 path = tmp_path / "ref"
515 write_text_atomic(path, "clean")
516 assert _tmp_files(tmp_path) == []
517
518 def test_no_orphan_after_replace_failure(self, tmp_path: pathlib.Path) -> None:
519 """When os.replace raises, the temp file must be unlinked."""
520 path = tmp_path / "ref"
521 with pytest.raises(OSError):
522 with patch("muse.core.store.os.replace", side_effect=OSError("disk full")):
523 write_text_atomic(path, "will fail")
524 assert _tmp_files(tmp_path) == [], "Orphan temp file left after replace failure"
525
526 def test_overwrites_existing_file(self, tmp_path: pathlib.Path) -> None:
527 """Subsequent writes must atomically replace the old content."""
528 path = tmp_path / "ref"
529 write_text_atomic(path, "old")
530 write_text_atomic(path, "new")
531 assert path.read_text() == "new"
532
533 def test_encoding_respected(self, tmp_path: pathlib.Path) -> None:
534 path = tmp_path / "utf8"
535 write_text_atomic(path, "caf\u00e9", encoding="utf-8")
536 assert path.read_text(encoding="utf-8") == "caf\u00e9"
537
538 def test_50_concurrent_writes_same_path_no_corruption(self, tmp_path: pathlib.Path) -> None:
539 """50 threads writing to the same file — last write wins, no corruption."""
540 path = tmp_path / "shared_ref"
541 errors: list[str] = []
542
543 def writer(i: int) -> None:
544 try:
545 write_text_atomic(path, f"value-{i:04d}")
546 except Exception as exc:
547 errors.append(str(exc))
548
549 threads = [threading.Thread(target=writer, args=(i,)) for i in range(50)]
550 for t in threads:
551 t.start()
552 for t in threads:
553 t.join()
554
555 assert errors == [], f"write_text_atomic raised: {errors}"
556 content = path.read_text()
557 assert content.startswith("value-"), f"Corrupt content: {content!r}"
558 assert _tmp_files(tmp_path) == [], "Orphan temp files after concurrent writes"
559
560 def test_100_concurrent_writes_distinct_paths_all_land(self, tmp_path: pathlib.Path) -> None:
561 """100 threads writing to distinct paths — all must land correctly."""
562 paths = [tmp_path / f"ref-{i:03d}" for i in range(100)]
563 errors: list[str] = []
564
565 def writer(p: pathlib.Path, i: int) -> None:
566 try:
567 write_text_atomic(p, f"commit-{i}")
568 if p.read_text() != f"commit-{i}":
569 errors.append(f"Mismatch at {p.name}")
570 except Exception as exc:
571 errors.append(str(exc))
572
573 threads = [threading.Thread(target=writer, args=(p, i)) for i, p in enumerate(paths)]
574 for t in threads:
575 t.start()
576 for t in threads:
577 t.join()
578
579 assert errors == [], f"Concurrent distinct-path errors: {errors}"
580 for i, p in enumerate(paths):
581 assert p.read_text() == f"commit-{i}"
582
583
584 # ---------------------------------------------------------------------------
585 # Tier 1a: write_head_branch and write_head_commit
586 # ---------------------------------------------------------------------------
587
588 class TestHeadWrites:
589 """HEAD files are the most critical VCS state — a corrupt HEAD breaks the repo."""
590
591 def _init(self, tmp_path: pathlib.Path) -> pathlib.Path:
592 (tmp_path / ".muse").mkdir()
593 (tmp_path / ".muse" / "refs" / "heads").mkdir(parents=True)
594 return tmp_path
595
596 def test_write_head_branch_correct_format(self, tmp_path: pathlib.Path) -> None:
597 root = self._init(tmp_path)
598 write_head_branch(root, "main")
599 content = (root / ".muse" / "HEAD").read_text()
600 assert content == "ref: refs/heads/main\n"
601
602 def test_write_head_branch_is_atomic(self, tmp_path: pathlib.Path) -> None:
603 """write_head_branch must go through write_text_atomic (mkstemp + fsync)."""
604 root = self._init(tmp_path)
605 called = [False]
606 real_mkstemp = tempfile.mkstemp
607
608 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
609 called[0] = True
610 return real_mkstemp(dir=dir, prefix=prefix)
611
612 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
613 write_head_branch(root, "main")
614
615 assert called[0], "write_head_branch bypassed mkstemp (not atomic)"
616
617 def test_write_head_branch_rejects_invalid_name(self, tmp_path: pathlib.Path) -> None:
618 root = self._init(tmp_path)
619 with pytest.raises((ValueError, SystemExit)):
620 write_head_branch(root, "bad/../../traversal")
621
622 def test_write_head_commit_correct_format(self, tmp_path: pathlib.Path) -> None:
623 root = self._init(tmp_path)
624 cid = "a" * 64
625 write_head_commit(root, cid)
626 content = (root / ".muse" / "HEAD").read_text()
627 assert content == f"commit: {cid}\n"
628
629 def test_write_head_commit_is_atomic(self, tmp_path: pathlib.Path) -> None:
630 root = self._init(tmp_path)
631 called = [False]
632 real_mkstemp = tempfile.mkstemp
633
634 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
635 called[0] = True
636 return real_mkstemp(dir=dir, prefix=prefix)
637
638 cid = "b" * 64
639 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
640 write_head_commit(root, cid)
641
642 assert called[0], "write_head_commit bypassed mkstemp (not atomic)"
643
644 def test_write_head_commit_rejects_short_id(self, tmp_path: pathlib.Path) -> None:
645 root = self._init(tmp_path)
646 with pytest.raises(ValueError, match="64-char"):
647 write_head_commit(root, "abc123")
648
649 def test_write_head_commit_rejects_non_hex(self, tmp_path: pathlib.Path) -> None:
650 root = self._init(tmp_path)
651 with pytest.raises(ValueError):
652 write_head_commit(root, "z" * 64)
653
654 def test_head_survives_concurrent_branch_switches(self, tmp_path: pathlib.Path) -> None:
655 """50 threads racing to update HEAD — no corruption, HEAD always readable."""
656 root = self._init(tmp_path)
657 errors: list[str] = []
658 branch_names = [f"feat-{i:03d}" for i in range(50)]
659
660 def switcher(branch: str) -> None:
661 try:
662 write_head_branch(root, branch)
663 content = (root / ".muse" / "HEAD").read_text()
664 if not content.startswith("ref: refs/heads/"):
665 errors.append(f"HEAD corrupted: {content!r}")
666 except Exception as exc:
667 errors.append(str(exc))
668
669 threads = [threading.Thread(target=switcher, args=(b,)) for b in branch_names]
670 for t in threads:
671 t.start()
672 for t in threads:
673 t.join()
674
675 assert errors == [], f"HEAD corruption detected: {errors}"
676 assert _tmp_files(tmp_path) == []
677
678
679 # ---------------------------------------------------------------------------
680 # Tier 1b: write_branch_ref — canonical branch pointer update
681 # ---------------------------------------------------------------------------
682
683 class TestWriteBranchRef:
684 """Branch refs are the second most critical VCS state.
685
686 A corrupt or missing ref orphans all commits reachable only from that branch.
687 """
688
689 def _init(self, tmp_path: pathlib.Path) -> pathlib.Path:
690 (tmp_path / ".muse" / "refs" / "heads").mkdir(parents=True)
691 return tmp_path
692
693 def _valid_cid(self, seed: str = "x") -> str:
694 return hashlib.sha256(seed.encode()).hexdigest()
695
696 def test_writes_correct_content(self, tmp_path: pathlib.Path) -> None:
697 root = self._init(tmp_path)
698 cid = self._valid_cid("test")
699 write_branch_ref(root, "main", cid)
700 ref_path = root / ".muse" / "refs" / "heads" / "main"
701 assert ref_path.read_text() == cid
702
703 def test_is_atomic_uses_mkstemp(self, tmp_path: pathlib.Path) -> None:
704 root = self._init(tmp_path)
705 called = [False]
706 real_mkstemp = tempfile.mkstemp
707
708 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
709 called[0] = True
710 return real_mkstemp(dir=dir, prefix=prefix)
711
712 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
713 write_branch_ref(root, "main", self._valid_cid())
714
715 assert called[0], "write_branch_ref bypassed mkstemp (not atomic)"
716
717 def test_fsync_called_before_replace(self, tmp_path: pathlib.Path) -> None:
718 root = self._init(tmp_path)
719 call_order: list[str] = []
720 real_fsync = os.fsync
721 real_replace = os.replace
722
723 def t_fsync(fd: int) -> None:
724 call_order.append("fsync")
725 real_fsync(fd)
726
727 def t_replace(src: str | bytes | os.PathLike[str], dst: str | bytes | os.PathLike[str]) -> None:
728 call_order.append("replace")
729 real_replace(src, dst)
730
731 with patch("muse.core.store.os.fsync", side_effect=t_fsync), \
732 patch("muse.core.store.os.replace", side_effect=t_replace):
733 write_branch_ref(root, "main", self._valid_cid())
734
735 fsync_idx = next((i for i, c in enumerate(call_order) if c == "fsync"), None)
736 replace_idx = next((i for i, c in enumerate(call_order) if c == "replace"), None)
737 assert fsync_idx is not None, "fsync not called in write_branch_ref"
738 assert replace_idx is not None, "replace not called in write_branch_ref"
739 assert fsync_idx < replace_idx, "fsync must precede replace"
740
741 def test_rejects_invalid_branch_name(self, tmp_path: pathlib.Path) -> None:
742 root = self._init(tmp_path)
743 with pytest.raises((ValueError, SystemExit)):
744 write_branch_ref(root, "../escape", self._valid_cid())
745
746 def test_rejects_non_hex_commit_id(self, tmp_path: pathlib.Path) -> None:
747 root = self._init(tmp_path)
748 with pytest.raises(ValueError):
749 write_branch_ref(root, "main", "z" * 64)
750
751 def test_rejects_short_commit_id(self, tmp_path: pathlib.Path) -> None:
752 root = self._init(tmp_path)
753 with pytest.raises(ValueError):
754 write_branch_ref(root, "main", "abc123")
755
756 def test_no_orphan_after_success(self, tmp_path: pathlib.Path) -> None:
757 root = self._init(tmp_path)
758 write_branch_ref(root, "main", self._valid_cid())
759 assert _tmp_files(tmp_path) == []
760
761 def test_no_orphan_after_replace_failure(self, tmp_path: pathlib.Path) -> None:
762 root = self._init(tmp_path)
763 with pytest.raises(OSError):
764 with patch("muse.core.store.os.replace", side_effect=OSError("disk full")):
765 write_branch_ref(root, "main", self._valid_cid())
766 assert _tmp_files(tmp_path) == []
767
768 def test_creates_nested_branch_path(self, tmp_path: pathlib.Path) -> None:
769 """Branches like feat/my-thing require parent dir creation."""
770 root = self._init(tmp_path)
771 cid = self._valid_cid("nested")
772 write_branch_ref(root, "feat/my-thing", cid)
773 ref_path = root / ".muse" / "refs" / "heads" / "feat" / "my-thing"
774 assert ref_path.read_text() == cid
775
776 def test_50_concurrent_refs_distinct_branches(self, tmp_path: pathlib.Path) -> None:
777 """50 concurrent writes to 50 distinct branches — all must land correctly."""
778 root = self._init(tmp_path)
779 branches = [f"agent-{i:04d}" for i in range(50)]
780 cids = {b: self._valid_cid(b) for b in branches}
781 errors: list[str] = []
782
783 def writer(branch: str) -> None:
784 try:
785 write_branch_ref(root, branch, cids[branch])
786 ref_path = root / ".muse" / "refs" / "heads" / branch
787 got = ref_path.read_text()
788 if got != cids[branch]:
789 errors.append(f"{branch}: expected {cids[branch][:8]}, got {got[:8]}")
790 except Exception as exc:
791 errors.append(f"{branch}: {exc}")
792
793 threads = [threading.Thread(target=writer, args=(b,)) for b in branches]
794 for t in threads:
795 t.start()
796 for t in threads:
797 t.join()
798
799 assert errors == [], f"Concurrent branch ref errors: {errors}"
800 assert _tmp_files(tmp_path) == []
801
802 def test_50_concurrent_refs_same_branch(self, tmp_path: pathlib.Path) -> None:
803 """50 concurrent writes to the SAME branch — last wins, no corruption."""
804 root = self._init(tmp_path)
805 cids = [self._valid_cid(f"race-{i}") for i in range(50)]
806 errors: list[str] = []
807
808 def writer(cid: str) -> None:
809 try:
810 write_branch_ref(root, "main", cid)
811 content = (root / ".muse" / "refs" / "heads" / "main").read_text()
812 if len(content) != 64 or not all(c in "0123456789abcdef" for c in content):
813 errors.append(f"Corrupt content after write: {content!r}")
814 except Exception as exc:
815 errors.append(str(exc))
816
817 threads = [threading.Thread(target=writer, args=(c,)) for c in cids]
818 for t in threads:
819 t.start()
820 for t in threads:
821 t.join()
822
823 assert errors == [], f"Same-branch concurrent errors: {errors}"
824 assert _tmp_files(tmp_path) == []
825
826
827 # ---------------------------------------------------------------------------
828 # Tier 2a: write_merge_state — MERGE_STATE.json
829 # ---------------------------------------------------------------------------
830
831 class TestMergeStateWrite:
832 """MERGE_STATE.json records in-progress conflict state.
833
834 A corrupt file prevents muse commit from completing a conflicted merge.
835 """
836
837 def _init(self, tmp_path: pathlib.Path) -> pathlib.Path:
838 (tmp_path / ".muse").mkdir()
839 return tmp_path
840
841 def _cid(self, seed: str) -> str:
842 return hashlib.sha256(seed.encode()).hexdigest()
843
844 def test_writes_valid_json(self, tmp_path: pathlib.Path) -> None:
845 from muse.core.merge_engine import write_merge_state
846 root = self._init(tmp_path)
847 write_merge_state(
848 root,
849 base_commit=self._cid("base"),
850 ours_commit=self._cid("ours"),
851 theirs_commit=self._cid("theirs"),
852 conflict_paths=["a.py", "b.py"],
853 )
854 state_path = root / ".muse" / "MERGE_STATE.json"
855 data = json.loads(state_path.read_text())
856 assert data["conflict_paths"] == ["a.py", "b.py"]
857
858 def test_is_atomic(self, tmp_path: pathlib.Path) -> None:
859 """write_merge_state must funnel through write_text_atomic."""
860 from muse.core.merge_engine import write_merge_state
861 root = self._init(tmp_path)
862 called = [False]
863 real_mkstemp = tempfile.mkstemp
864
865 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
866 called[0] = True
867 return real_mkstemp(dir=dir, prefix=prefix)
868
869 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
870 write_merge_state(
871 root,
872 base_commit=self._cid("b"),
873 ours_commit=self._cid("o"),
874 theirs_commit=self._cid("t"),
875 conflict_paths=[],
876 )
877
878 assert called[0], "write_merge_state bypassed mkstemp (not atomic)"
879
880 def test_no_orphan_after_success(self, tmp_path: pathlib.Path) -> None:
881 from muse.core.merge_engine import write_merge_state
882 root = self._init(tmp_path)
883 write_merge_state(
884 root,
885 base_commit=self._cid("b"),
886 ours_commit=self._cid("o"),
887 theirs_commit=self._cid("t"),
888 conflict_paths=["x.py"],
889 )
890 assert _tmp_files(tmp_path) == []
891
892
893 # ---------------------------------------------------------------------------
894 # Tier 2b: save_rebase_state — REBASE_STATE.json
895 # ---------------------------------------------------------------------------
896
897 class TestRebaseStateWrite:
898 def _init(self, tmp_path: pathlib.Path) -> pathlib.Path:
899 (tmp_path / ".muse").mkdir()
900 return tmp_path
901
902 def _state(self) -> RebaseState:
903 cid = hashlib.sha256(b"c").hexdigest()
904 return RebaseState(
905 original_branch="main",
906 original_head=cid,
907 onto=cid,
908 remaining=[],
909 completed=[],
910 squash=False,
911 )
912
913 def test_writes_valid_json(self, tmp_path: pathlib.Path) -> None:
914 from muse.core.rebase import save_rebase_state
915 root = self._init(tmp_path)
916 save_rebase_state(root, self._state())
917 path = root / ".muse" / "REBASE_STATE.json"
918 data = json.loads(path.read_text())
919 assert data["original_branch"] == "main"
920
921 def test_is_atomic(self, tmp_path: pathlib.Path) -> None:
922 from muse.core.rebase import save_rebase_state
923 root = self._init(tmp_path)
924 called = [False]
925 real_mkstemp = tempfile.mkstemp
926
927 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
928 called[0] = True
929 return real_mkstemp(dir=dir, prefix=prefix)
930
931 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
932 save_rebase_state(root, self._state())
933
934 assert called[0], "save_rebase_state bypassed mkstemp"
935
936 def test_no_orphan_after_success(self, tmp_path: pathlib.Path) -> None:
937 from muse.core.rebase import save_rebase_state
938 root = self._init(tmp_path)
939 save_rebase_state(root, self._state())
940 assert _tmp_files(tmp_path) == []
941
942
943 # ---------------------------------------------------------------------------
944 # Tier 2c: coordination.py — reservation + intent writes
945 # ---------------------------------------------------------------------------
946
947 class TestCoordinationWrites:
948 def _init(self, tmp_path: pathlib.Path) -> pathlib.Path:
949 (tmp_path / ".muse").mkdir()
950 return tmp_path
951
952 def _res(self, root: pathlib.Path, i: int = 0) -> Reservation:
953 from muse.core.coordination import create_reservation
954 return create_reservation(
955 root,
956 run_id=f"run-{i}",
957 branch="main",
958 addresses=[f"addr-{i}"],
959 operation="write",
960 )
961
962 def test_create_reservation_is_atomic(self, tmp_path: pathlib.Path) -> None:
963 from muse.core.coordination import create_reservation
964 root = self._init(tmp_path)
965 called = [False]
966 real_mkstemp = tempfile.mkstemp
967
968 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
969 called[0] = True
970 return real_mkstemp(dir=dir, prefix=prefix)
971
972 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
973 create_reservation(root, run_id="r1", branch="main", addresses=["a"], operation="write")
974
975 assert called[0], "create_reservation bypassed mkstemp"
976
977 def test_create_reservation_writes_valid_json(self, tmp_path: pathlib.Path) -> None:
978 from muse.core.coordination import _reservations_dir
979 root = self._init(tmp_path)
980 res = self._res(root, 0)
981 res_path = _reservations_dir(root) / f"{res.reservation_id}.json"
982 data = json.loads(res_path.read_text())
983 assert data["operation"] == "write"
984
985 def test_create_intent_is_atomic(self, tmp_path: pathlib.Path) -> None:
986 from muse.core.coordination import create_intent
987 root = self._init(tmp_path)
988 res = self._res(root)
989 called = [False]
990 real_mkstemp = tempfile.mkstemp
991
992 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
993 called[0] = True
994 return real_mkstemp(dir=dir, prefix=prefix)
995
996 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
997 create_intent(
998 root,
999 reservation_id=res.reservation_id,
1000 run_id="r1",
1001 branch="main",
1002 addresses=["a"],
1003 operation="merge",
1004 )
1005
1006 assert called[0], "create_intent bypassed mkstemp"
1007
1008 def test_create_intent_writes_valid_json(self, tmp_path: pathlib.Path) -> None:
1009 from muse.core.coordination import create_intent, _intents_dir
1010 root = self._init(tmp_path)
1011 res = self._res(root)
1012 intent = create_intent(
1013 root,
1014 reservation_id=res.reservation_id,
1015 run_id="r1",
1016 branch="main",
1017 addresses=["a"],
1018 operation="push",
1019 )
1020 intent_path = _intents_dir(root) / f"{intent.intent_id}.json"
1021 data = json.loads(intent_path.read_text())
1022 assert data["operation"] == "push"
1023
1024 def test_no_orphan_after_reservation(self, tmp_path: pathlib.Path) -> None:
1025 root = self._init(tmp_path)
1026 self._res(root)
1027 assert _tmp_files(tmp_path) == []
1028
1029 def test_no_orphan_after_intent(self, tmp_path: pathlib.Path) -> None:
1030 from muse.core.coordination import create_intent
1031 root = self._init(tmp_path)
1032 res = self._res(root)
1033 create_intent(
1034 root,
1035 reservation_id=res.reservation_id,
1036 run_id="r1",
1037 branch="main",
1038 addresses=["a"],
1039 operation="commit",
1040 )
1041 assert _tmp_files(tmp_path) == []
1042
1043 def test_20_concurrent_reservation_writes(self, tmp_path: pathlib.Path) -> None:
1044 """20 concurrent agents creating reservations — no corruption, no orphans."""
1045 from muse.core.coordination import create_reservation, _reservations_dir
1046 root = self._init(tmp_path)
1047 errors: list[str] = []
1048
1049 def writer(i: int) -> None:
1050 try:
1051 create_reservation(
1052 root,
1053 run_id=f"run-{i}",
1054 branch="main",
1055 addresses=[f"addr-{i}"],
1056 operation="write",
1057 )
1058 except Exception as exc:
1059 errors.append(str(exc))
1060
1061 threads = [threading.Thread(target=writer, args=(i,)) for i in range(20)]
1062 for t in threads:
1063 t.start()
1064 for t in threads:
1065 t.join()
1066
1067 assert errors == [], f"Concurrent reservation errors: {errors}"
1068 reservation_files = list(_reservations_dir(root).glob("*.json"))
1069 assert len(reservation_files) == 20, f"Expected 20 reservation files, got {len(reservation_files)}"
1070 assert _tmp_files(tmp_path) == []
1071
1072
1073 # ---------------------------------------------------------------------------
1074 # Tier 3: config.py — TOML config writes
1075 # ---------------------------------------------------------------------------
1076
1077 class TestConfigWrites:
1078 """Config files govern remote connections, auth, and repo settings.
1079
1080 A corrupt config.toml prevents all repo operations.
1081 """
1082
1083 def _init_config_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
1084 """Create a minimal repo with config.toml so config helpers can operate."""
1085 muse_dir = tmp_path / ".muse"
1086 muse_dir.mkdir()
1087 (muse_dir / "objects").mkdir()
1088 (muse_dir / "commits").mkdir()
1089 (muse_dir / "snapshots").mkdir()
1090 (muse_dir / "refs" / "heads").mkdir(parents=True)
1091 (muse_dir / "repo.json").write_text(
1092 '{"repo_id": "test-repo", "domain": "code", "default_branch": "main"}',
1093 encoding="utf-8",
1094 )
1095 (muse_dir / "config.toml").write_text("", encoding="utf-8")
1096 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
1097 return tmp_path
1098
1099 def test_set_remote_is_atomic(self, tmp_path: pathlib.Path) -> None:
1100 """set_remote (writes config.toml) must funnel through write_text_atomic."""
1101 from muse.cli.config import set_remote
1102 root = self._init_config_repo(tmp_path)
1103 called = [False]
1104 real_mkstemp = tempfile.mkstemp
1105
1106 def tracking(dir: pathlib.Path | None = None, prefix: str = "") -> tuple[int, str]:
1107 called[0] = True
1108 return real_mkstemp(dir=dir, prefix=prefix)
1109
1110 with patch("muse.core.store.tempfile.mkstemp", side_effect=tracking):
1111 set_remote("local", "http://localhost:10003", repo_root=root)
1112
1113 assert called[0], "set_remote bypassed mkstemp (config write not atomic)"
1114
1115 def test_set_remote_no_orphan(self, tmp_path: pathlib.Path) -> None:
1116 from muse.cli.config import set_remote
1117 root = self._init_config_repo(tmp_path)
1118 set_remote("origin", "http://localhost:10003", repo_root=root)
1119 assert _tmp_files(tmp_path) == []
1120
1121 def test_set_remote_correct_content_persisted(self, tmp_path: pathlib.Path) -> None:
1122 from muse.cli.config import set_remote, get_remote
1123 root = self._init_config_repo(tmp_path)
1124 set_remote("myremote", "http://myhost:9000", repo_root=root)
1125 url = get_remote("myremote", repo_root=root)
1126 assert url == "http://myhost:9000"
1127
1128 def test_10_concurrent_config_writes_no_orphan(self, tmp_path: pathlib.Path) -> None:
1129 """10 concurrent set_remote calls — no orphan temp files."""
1130 from muse.cli.config import set_remote
1131 root = self._init_config_repo(tmp_path)
1132 errors: list[str] = []
1133
1134 def writer(i: int) -> None:
1135 try:
1136 set_remote(f"remote-{i}", f"http://host-{i}:9000", repo_root=root)
1137 except Exception as exc:
1138 errors.append(str(exc))
1139
1140 threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
1141 for t in threads:
1142 t.start()
1143 for t in threads:
1144 t.join()
1145
1146 # No orphan temps regardless of config merge conflicts
1147 assert _tmp_files(tmp_path) == []
1148
1149
1150 # ---------------------------------------------------------------------------
1151 # Gap 2+3: write_object_from_path — fsync ordering + copy2 failure cleanup
1152 # ---------------------------------------------------------------------------
1153
1154 class TestWriteObjectFromPathFsync:
1155 """_fsync_fd must be called before os.replace in write_object_from_path.
1156
1157 write_object_from_path uses shutil.copy2 then re-opens the temp file as
1158 an fd to call fchmod + _fsync_fd before the atomic rename. The test
1159 patches _fsync_fd (the fd-based variant) — NOT _fsync_path, which is the
1160 path-based variant used only by restore_object.
1161 """
1162
1163 def test_fsync_path_called_before_replace(self, tmp_path: pathlib.Path) -> None:
1164 """_fsync_fd must be invoked before os.replace."""
1165 repo = _repo(tmp_path)
1166 data = b"from-path fsync ordering"
1167 oid = _oid(data)
1168 src = tmp_path / "source.bin"
1169 src.write_bytes(data)
1170
1171 call_order: list[str] = []
1172 real_fsync_fd = __import__("muse.core.object_store", fromlist=["_fsync_fd"])._fsync_fd
1173 real_replace = os.replace
1174
1175 def t_fsync_fd(fd: int) -> None:
1176 call_order.append("fsync_fd")
1177 real_fsync_fd(fd)
1178
1179 def t_replace(s: str | bytes | os.PathLike[str], d: str | bytes | os.PathLike[str]) -> None:
1180 call_order.append("replace")
1181 real_replace(s, d)
1182
1183 with patch("muse.core.object_store._fsync_fd", side_effect=t_fsync_fd), \
1184 patch("muse.core.object_store.os.replace", side_effect=t_replace):
1185 write_object_from_path(repo, oid, src)
1186
1187 fp = next((i for i, c in enumerate(call_order) if c == "fsync_fd"), None)
1188 rp = next((i for i, c in enumerate(call_order) if c == "replace"), None)
1189 assert fp is not None, "_fsync_fd never called in write_object_from_path"
1190 assert rp is not None, "os.replace never called in write_object_from_path"
1191 assert fp < rp, f"_fsync_fd (pos {fp}) must happen before replace (pos {rp})"
1192
1193 def test_fsync_path_failure_non_fatal(self, tmp_path: pathlib.Path) -> None:
1194 """os.fsync failure inside _fsync_fd must not abort write_object_from_path.
1195
1196 _fsync_fd swallows OSError internally — we patch os.fsync so the
1197 function's own try/except absorbs the failure, exactly as it would on a
1198 filesystem that does not support fsync (tmpfs, some Docker volumes).
1199 """
1200 repo = _repo(tmp_path)
1201 data = b"fsync_path fails gracefully"
1202 oid = _oid(data)
1203 src = tmp_path / "src.bin"
1204 src.write_bytes(data)
1205
1206 with patch("muse.core.object_store.os.fsync", side_effect=OSError("not supported")):
1207 result = write_object_from_path(repo, oid, src)
1208
1209 assert result is True
1210 assert read_object(repo, oid) == data
1211
1212 def test_no_orphan_after_copy2_failure(self, tmp_path: pathlib.Path) -> None:
1213 """When shutil.copy2 raises, the mkstemp temp file must be cleaned up."""
1214 import shutil
1215 repo = _repo(tmp_path)
1216 data = b"copy2 will fail"
1217 oid = _oid(data)
1218 src = tmp_path / "src.bin"
1219 src.write_bytes(data)
1220
1221 with pytest.raises(OSError):
1222 with patch("muse.core.object_store.shutil.copy2", side_effect=OSError("I/O error")):
1223 write_object_from_path(repo, oid, src)
1224
1225 assert _tmp_files(tmp_path) == [], "Orphan temp after shutil.copy2 failure"
1226
1227 def test_no_orphan_after_replace_failure(self, tmp_path: pathlib.Path) -> None:
1228 """When os.replace raises, the temp file must be cleaned up."""
1229 repo = _repo(tmp_path)
1230 data = b"replace will fail for from_path"
1231 oid = _oid(data)
1232 src = tmp_path / "src.bin"
1233 src.write_bytes(data)
1234
1235 with pytest.raises(OSError):
1236 with patch("muse.core.object_store.os.replace", side_effect=OSError("disk full")):
1237 write_object_from_path(repo, oid, src)
1238
1239 assert _tmp_files(tmp_path) == [], "Orphan temp after os.replace failure in write_object_from_path"
1240
1241 def test_correct_content_after_write(self, tmp_path: pathlib.Path) -> None:
1242 """Content round-trips correctly through write_object_from_path → read_object."""
1243 repo = _repo(tmp_path)
1244 data = os.urandom(4096)
1245 oid = _oid(data)
1246 src = tmp_path / "payload.bin"
1247 src.write_bytes(data)
1248
1249 write_object_from_path(repo, oid, src)
1250 assert read_object(repo, oid) == data
1251
1252
1253 # ---------------------------------------------------------------------------
1254 # Gap 4+5+6: restore_object — fsync ordering + copy2 failure cleanup
1255 # ---------------------------------------------------------------------------
1256
1257 class TestRestoreObjectFsync:
1258 """_fsync_path must be called before os.replace in restore_object."""
1259
1260 def test_fsync_path_called_before_replace(self, tmp_path: pathlib.Path) -> None:
1261 repo = _repo(tmp_path)
1262 data = b"restore fsync ordering"
1263 oid = _oid(data)
1264 write_object(repo, oid, data)
1265 dest = tmp_path / "restored.bin"
1266
1267 call_order: list[str] = []
1268 real_fsync_path = __import__("muse.core.object_store", fromlist=["_fsync_path"])._fsync_path
1269 real_replace = os.replace
1270
1271 def t_fsync_path(path: pathlib.Path) -> None:
1272 call_order.append("fsync_path")
1273 real_fsync_path(path)
1274
1275 def t_replace(s: str | bytes | os.PathLike[str], d: str | bytes | os.PathLike[str]) -> None:
1276 call_order.append("replace")
1277 real_replace(s, d)
1278
1279 with patch("muse.core.object_store._fsync_path", side_effect=t_fsync_path), \
1280 patch("muse.core.object_store.os.replace", side_effect=t_replace):
1281 restore_object(repo, oid, dest)
1282
1283 fp = next((i for i, c in enumerate(call_order) if c == "fsync_path"), None)
1284 rp = next((i for i, c in enumerate(call_order) if c == "replace"), None)
1285 assert fp is not None, "_fsync_path never called in restore_object"
1286 assert rp is not None, "os.replace never called in restore_object"
1287 assert fp < rp, f"_fsync_path (pos {fp}) must precede replace (pos {rp})"
1288
1289 def test_fsync_path_failure_non_fatal(self, tmp_path: pathlib.Path) -> None:
1290 """os.fsync failure inside _fsync_path must not abort restore_object."""
1291 repo = _repo(tmp_path)
1292 data = b"restore fsync_path fails gracefully"
1293 oid = _oid(data)
1294 write_object(repo, oid, data)
1295 dest = tmp_path / "restored.bin"
1296
1297 with patch("muse.core.object_store.os.fsync", side_effect=OSError("not supported")):
1298 result = restore_object(repo, oid, dest)
1299
1300 assert result is True
1301 assert dest.read_bytes() == data
1302
1303 def test_no_orphan_after_copy2_failure(self, tmp_path: pathlib.Path) -> None:
1304 repo = _repo(tmp_path)
1305 data = b"restore copy2 will fail"
1306 oid = _oid(data)
1307 write_object(repo, oid, data)
1308 dest = tmp_path / "out.bin"
1309
1310 with pytest.raises(OSError):
1311 with patch("muse.core.object_store.shutil.copy2", side_effect=OSError("I/O error")):
1312 restore_object(repo, oid, dest)
1313
1314 assert _tmp_files(tmp_path) == [], "Orphan temp after copy2 failure in restore_object"
1315
1316 def test_no_orphan_after_replace_failure(self, tmp_path: pathlib.Path) -> None:
1317 repo = _repo(tmp_path)
1318 data = b"restore replace will fail"
1319 oid = _oid(data)
1320 write_object(repo, oid, data)
1321 dest = tmp_path / "out.bin"
1322
1323 with pytest.raises(OSError):
1324 with patch("muse.core.object_store.os.replace", side_effect=OSError("disk full")):
1325 restore_object(repo, oid, dest)
1326
1327 assert _tmp_files(tmp_path) == [], "Orphan temp after os.replace failure in restore_object"
1328
1329 def test_restored_file_mtime_is_current_not_from_object_store(
1330 self, tmp_path: pathlib.Path
1331 ) -> None:
1332 """restore_object must set the destination mtime to NOW, not to the
1333 object-store file's mtime.
1334
1335 shutil.copy2 propagates the source (object-store) mtime to the temp
1336 file. Object-store files are written at commit time and may be days
1337 or weeks old. Without os.utime(tmp, None) the restored destination
1338 carries an old timestamp, causing editors (Cursor, VS Code, Vim) to
1339 see "new mtime < cached mtime" and serve a stale buffer instead of
1340 reloading the file. This is the regression that caused the
1341 "merge work disappears in Cursor but reappears on close/reopen" bug.
1342 """
1343 import time
1344
1345 repo = _repo(tmp_path)
1346 data = b"content that differs from any existing file\n" * 10
1347 oid = _oid(data)
1348 write_object(repo, oid, data)
1349
1350 # Simulate an old object-store mtime: backdate the stored object to 2 days ago.
1351 obj_path = repo / ".muse" / "objects" / oid[:2] / oid[2:]
1352 two_days_ago = time.time() - (2 * 24 * 3600)
1353 os.utime(obj_path, (two_days_ago, two_days_ago))
1354
1355 # Write a pre-existing dest with a "current" mtime (simulating Cursor's
1356 # last-read timestamp before the checkout/merge).
1357 dest = tmp_path / "watched_file.py"
1358 dest.write_bytes(b"old content that cursor has open\n")
1359 cursor_cached_mtime = time.time()
1360 os.utime(dest, (cursor_cached_mtime, cursor_cached_mtime))
1361
1362 # restore_object must write the new content AND freshen mtime.
1363 t_before = time.time()
1364 restore_object(repo, oid, dest)
1365 t_after = time.time()
1366
1367 new_mtime = os.stat(dest).st_mtime
1368
1369 # Destination must have a FRESH timestamp — not the object-store's old one.
1370 assert new_mtime >= t_before, (
1371 f"Restored file mtime ({new_mtime:.2f}) is older than the time "
1372 f"restore_object was called ({t_before:.2f}). "
1373 "shutil.copy2 is propagating the object-store's stale mtime, "
1374 "which causes editors to serve stale buffers after checkout/merge."
1375 )
1376 assert new_mtime <= t_after + 1.0, (
1377 f"Restored file mtime ({new_mtime:.2f}) is far in the future — unexpected."
1378 )
1379
1380 # Content must be correct regardless.
1381 assert dest.read_bytes() == data
1382
1383 def test_restored_mtime_fresher_than_previous_content(
1384 self, tmp_path: pathlib.Path
1385 ) -> None:
1386 """After restore_object, the destination mtime must be >= the mtime it
1387 had before the call, so editors always see a forward-moving timestamp."""
1388 import time
1389
1390 repo = _repo(tmp_path)
1391 new_data = b"new version from feature branch\n"
1392 oid = _oid(new_data)
1393 write_object(repo, oid, new_data)
1394
1395 dest = tmp_path / "file.py"
1396 dest.write_bytes(b"old version on dev\n")
1397 old_mtime = time.time()
1398 os.utime(dest, (old_mtime, old_mtime))
1399
1400 # Backdate the object-store copy (as it would be after a real commit).
1401 obj_path = repo / ".muse" / "objects" / oid[:2] / oid[2:]
1402 os.utime(obj_path, (old_mtime - 86400, old_mtime - 86400))
1403
1404 restore_object(repo, oid, dest)
1405
1406 assert os.stat(dest).st_mtime >= old_mtime, (
1407 "Restored file mtime went backwards — editor will not see the change."
1408 )
1409
1410
1411 # ---------------------------------------------------------------------------
1412 # Gap 7: page-cache non-flush — defense-in-depth (I-1 catches what I-2 misses)
1413 # ---------------------------------------------------------------------------
1414
1415 class TestPageCacheDefenseInDepth:
1416 """Demonstrate that I-1 (read-time hash verification) is the safety net
1417 for the unlikely scenario where fsync appeared to succeed but the kernel
1418 wrote zero bytes to disk (power loss AFTER rename, BEFORE flush).
1419
1420 Simulated by: writing an object normally, then zeroing the on-disk file
1421 (mimicking a power-loss-induced empty file at the renamed destination).
1422 read_object must raise OSError — the store never silently serves bad data.
1423 """
1424
1425 def test_zeroed_dest_after_rename_caught_by_read(self, tmp_path: pathlib.Path) -> None:
1426 """Simulate post-rename page-cache loss: zero the stored file, then read."""
1427 repo = _repo(tmp_path)
1428 data = b"page cache simulation"
1429 oid = _oid(data)
1430 write_object(repo, oid, data)
1431
1432 # Mimic power loss that zeroed the file after rename.
1433 _corrupt_file(object_path(repo, oid), b"\x00" * len(data))
1434
1435 with pytest.raises(OSError, match="integrity check"):
1436 read_object(repo, oid)
1437
1438 def test_truncated_dest_caught_by_read(self, tmp_path: pathlib.Path) -> None:
1439 """Simulate partial flush: only first half of bytes survived power loss."""
1440 repo = _repo(tmp_path)
1441 data = b"partial flush simulation" * 10
1442 oid = _oid(data)
1443 write_object(repo, oid, data)
1444 # Only the first half survived to disk.
1445 _corrupt_file(object_path(repo, oid), data[: len(data) // 2])
1446
1447 with pytest.raises(OSError, match="integrity check"):
1448 read_object(repo, oid)
1449
1450 def test_noop_write_detected(self, tmp_path: pathlib.Path) -> None:
1451 """Simulate fh.write no-op (page cache accepted write, never flushed).
1452
1453 We write the object normally and then zero the stored file to mimic the
1454 outcome of a post-rename page-cache flush failure. read_object must
1455 raise OSError — I-1's hash check is the final safety net for any I-2
1456 failure mode.
1457 """
1458 repo = _repo(tmp_path)
1459 data = b"write syscall accepted but page cache never flushed"
1460 oid = _oid(data)
1461
1462 # Write correctly first, then simulate the power-loss outcome: the
1463 # renamed destination was never actually flushed to durable storage.
1464 write_object(repo, oid, data)
1465 stored = object_path(repo, oid)
1466 _corrupt_file(stored, b"") # zero bytes — what a power loss leaves
1467
1468 with pytest.raises(OSError, match="integrity check"):
1469 read_object(repo, oid)
1470
1471
1472 # ---------------------------------------------------------------------------
1473 # Gap 8: same object_id written from N threads simultaneously — idempotency
1474 # ---------------------------------------------------------------------------
1475
1476 class TestIdempotentConcurrentWrite:
1477 """write_object is idempotent: same object_id written from many threads
1478 concurrently must never produce corruption — only one write wins, others
1479 see exists() and skip. The content of the winner must be correct.
1480 """
1481
1482 def test_same_object_50_threads_no_corruption(self, tmp_path: pathlib.Path) -> None:
1483 """50 threads writing the same object_id must all succeed with correct content."""
1484 repo = _repo(tmp_path)
1485 data = b"idempotent object written from 50 threads"
1486 oid = _oid(data)
1487 errors: list[str] = []
1488
1489 def writer() -> None:
1490 try:
1491 write_object(repo, oid, data)
1492 result = read_object(repo, oid)
1493 if result != data:
1494 errors.append(f"Mismatch: {repr(result)[:30]}")
1495 except Exception as exc:
1496 errors.append(f"Exception: {exc}")
1497
1498 threads = [threading.Thread(target=writer) for _ in range(50)]
1499 for t in threads:
1500 t.start()
1501 for t in threads:
1502 t.join()
1503
1504 assert errors == [], f"Idempotent concurrent write errors:\n" + "\n".join(errors)
1505 assert read_object(repo, oid) == data
1506 assert _tmp_files(tmp_path) == []
1507
1508 def test_same_object_distinct_content_rejected(self, tmp_path: pathlib.Path) -> None:
1509 """Writing different bytes under the same object_id is always rejected."""
1510 repo = _repo(tmp_path)
1511 data = b"canonical content"
1512 oid = _oid(data)
1513 wrong = b"wrong content that hashes differently"
1514
1515 write_object(repo, oid, data)
1516
1517 with pytest.raises(ValueError, match="integrity"):
1518 write_object(repo, oid, wrong)
1519
1520 assert read_object(repo, oid) == data
1521
1522
1523 # ---------------------------------------------------------------------------
1524 # Gap 9: mid-write fh.write failure — orphan cleaned up
1525 # ---------------------------------------------------------------------------
1526
1527 class TestMidWriteFailureCleanup:
1528 """OSError raised during the fh.write call (disk full mid-write) must not
1529 leave an orphan temp file in the store directory."""
1530
1531 def test_no_orphan_after_write_failure_write_object(self, tmp_path: pathlib.Path) -> None:
1532 """OSError during fh.write must not leave an orphan temp file."""
1533 from unittest.mock import MagicMock
1534 repo = _repo(tmp_path)
1535 data = b"mid-write failure"
1536 oid = _oid(data)
1537
1538 mock_fh = MagicMock()
1539 mock_fh.__enter__.return_value = mock_fh
1540 mock_fh.write.side_effect = OSError("disk full")
1541 mock_fh.flush.return_value = None
1542 mock_fh.fileno.return_value = -1
1543
1544 with pytest.raises(OSError):
1545 with patch("muse.core.object_store.os.fdopen", return_value=mock_fh):
1546 write_object(repo, oid, data)
1547
1548 assert _tmp_files(tmp_path) == [], "Orphan temp file left after mid-write failure"
1549
1550 def test_no_orphan_after_write_failure_write_text_atomic(self, tmp_path: pathlib.Path) -> None:
1551 """write_text_atomic cleans up the temp file when fh.write raises."""
1552 from unittest.mock import MagicMock
1553 path = tmp_path / "state.txt"
1554
1555 mock_fh = MagicMock()
1556 mock_fh.__enter__.return_value = mock_fh
1557 mock_fh.write.side_effect = OSError("disk full")
1558 mock_fh.flush.return_value = None
1559 mock_fh.fileno.return_value = -1
1560
1561 with pytest.raises(OSError):
1562 with patch("muse.core.store.os.fdopen", return_value=mock_fh):
1563 write_text_atomic(path, "will fail")
1564
1565 assert _tmp_files(tmp_path) == [], "Orphan temp left after write_text_atomic mid-write failure"
1566
1567
1568 # ---------------------------------------------------------------------------
1569 # Gap 10: 10 000 sequential commits — store clean throughout
1570 # ---------------------------------------------------------------------------
1571
1572 class TestSequentialStress:
1573 """10 000 sequential commit writes exercise the full fsync+rename path at
1574 scale. The store must be clean (all readable, no orphans) when done.
1575
1576 Based on the Linux-kernel-migration scenario: Linus runs a git-to-muse
1577 import script that writes 75k commits. We test at 10k to keep CI fast.
1578 """
1579
1580 @pytest.mark.slow
1581 def test_10000_sequential_commits_all_readable(self, tmp_path: pathlib.Path) -> None:
1582 """10 000 sequential commits — every one must be readable after write."""
1583 repo = _repo(tmp_path)
1584 commits = [_commit(i) for i in range(10_000)]
1585
1586 for c in commits:
1587 write_commit(repo, c)
1588
1589 # Verify every commit is readable and correct.
1590 failures: list[str] = []
1591 for c in commits:
1592 result = read_commit(repo, c.commit_id)
1593 if result is None:
1594 failures.append(f"Commit {c.commit_id[:8]} not found after write")
1595 elif result.message != c.message:
1596 failures.append(f"Commit {c.commit_id[:8]} message corrupted")
1597
1598 assert failures == [], f"{len(failures)} commit read failures:\n" + "\n".join(failures[:10])
1599 assert _tmp_files(tmp_path) == [], "Orphan temps after 10k commit writes"
1600
1601 @pytest.mark.slow
1602 def test_1000_commits_with_20pct_fsync_failure_all_readable(
1603 self, tmp_path: pathlib.Path
1604 ) -> None:
1605 """1 000 commits with 20% random fsync failures must all land correctly.
1606
1607 Verifies that fsync failure is gracefully handled and atomicity (torn-write
1608 protection) is maintained even when durability (fsync) is degraded.
1609 """
1610 import random as _random
1611 repo = _repo(tmp_path)
1612 rng = _random.Random(42)
1613 commits = [_commit(i) for i in range(1_000)]
1614 real_fsync = os.fsync
1615
1616 def flaky_fsync(fd: int) -> None:
1617 if rng.random() < 0.2:
1618 raise OSError("simulated fsync failure")
1619 real_fsync(fd)
1620
1621 with patch("muse.core.store.os.fsync", side_effect=flaky_fsync):
1622 for c in commits:
1623 write_commit(repo, c)
1624
1625 failures: list[str] = []
1626 for c in commits:
1627 result = read_commit(repo, c.commit_id)
1628 if result is None:
1629 failures.append(f"Commit {c.commit_id[:8]} not found")
1630 elif result.message != c.message:
1631 failures.append(f"Commit {c.commit_id[:8]} corrupted")
1632
1633 assert failures == [], f"Commits lost under flaky fsync:\n" + "\n".join(failures)
1634 assert _tmp_files(tmp_path) == []
1635
1636
1637 # ---------------------------------------------------------------------------
1638 # Gap 11: SIGKILL crash safety — process kill leaves no orphans
1639 # ---------------------------------------------------------------------------
1640
1641 class TestProcessKillCrashSafety:
1642 """Simulate abrupt process termination (SIGKILL) during an object write.
1643
1644 Uses multiprocessing to run the writer in a child process, then kills it
1645 with SIGKILL at a random moment. Afterward, the store must be consistent:
1646 - Objects fully written before the kill must be readable and hash-correct.
1647 - No orphan temp files must remain (OS cleans up open fds; the temp file
1648 created by mkstemp is unlinked by the OS when the process dies, since
1649 it holds the only reference via the fd).
1650
1651 Note: On most POSIX systems, a SIGKILL'd process that holds an open
1652 mkstemp fd will have that fd closed by the kernel. The temp file remains
1653 on disk (the fd close doesn't unlink it) but the rename never happens, so
1654 the destination is either fully written or absent — never partial.
1655 This test verifies the store consistency guarantee, not orphan cleanup
1656 (orphan GC is a separate I-6 concern).
1657 """
1658
1659 @pytest.mark.slow
1660 def test_sigkill_during_write_leaves_no_partial_dest(
1661 self, tmp_path: pathlib.Path
1662 ) -> None:
1663 """Objects written before SIGKILL must still be readable after kill."""
1664 import multiprocessing
1665 import signal
1666 import time
1667
1668 repo = _repo(tmp_path)
1669
1670 # Write 20 known objects before spawning the crashable process.
1671 pre_oids: list[str] = []
1672 for i in range(20):
1673 data = f"pre-kill-{i}".encode()
1674 oid = _oid(data)
1675 write_object(repo, oid, data)
1676 pre_oids.append(oid)
1677
1678 # "spawn" starts a fresh interpreter — no multi-threaded-fork warning
1679 # and no risk of deadlocks inherited from the pytest runner's threads.
1680 # _sigkill_writer_worker is defined at module level to ensure it is
1681 # picklable across the spawn boundary.
1682 ctx = multiprocessing.get_context("spawn")
1683 proc = ctx.Process(target=_sigkill_writer_worker, args=(repo, 5000))
1684 proc.start()
1685
1686 # Kill the worker after a short random delay.
1687 import random
1688 time.sleep(random.uniform(0.01, 0.05))
1689 if proc.is_alive():
1690 assert proc.pid is not None
1691 os.kill(proc.pid, signal.SIGKILL)
1692 proc.join()
1693
1694 # All pre-kill objects must still be readable and correct.
1695 for i, oid in enumerate(pre_oids):
1696 data = f"pre-kill-{i}".encode()
1697 result = read_object(repo, oid)
1698 assert result == data, f"Pre-kill object {oid[:8]} corrupted after SIGKILL"
1699
1700 # Store consistency: every object file in the store must hash-verify.
1701 import muse.core.object_store as _ost
1702 all_oids = _ost._iter_all_object_ids(repo) if hasattr(_ost, "_iter_all_object_ids") else []
1703 for oid in all_oids:
1704 try:
1705 read_object(repo, oid) # raises on hash mismatch
1706 except OSError as exc:
1707 pytest.fail(f"Corrupt object {oid[:8]} found after SIGKILL: {exc}")
1708
1709
1710 # ---------------------------------------------------------------------------
1711 # Gap 12: Performance benchmark — 4 KiB msgpack write + fsync < 5 ms
1712 # ---------------------------------------------------------------------------
1713
1714 class TestFsyncWritePerformance:
1715 """fsync overhead on a 4 KiB msgpack file must be < 5 ms.
1716
1717 The syscall is dominated by the OS flush latency, not the data volume.
1718 tmpfs (which tmp_path typically uses on Linux) syncs instantly; on macOS
1719 with APFS this is also sub-millisecond. 5 ms is a very generous budget —
1720 a real NVMe commit flush is typically < 0.5 ms.
1721 """
1722
1723 @pytest.mark.perf
1724 def test_write_commit_4kib_under_5ms(self, tmp_path: pathlib.Path) -> None:
1725 """Single 4 KiB commit write (msgpack + fsync + rename) < 5 ms."""
1726 import time
1727 repo = _repo(tmp_path)
1728 c = _commit(99_000)
1729
1730 start = time.perf_counter()
1731 write_commit(repo, c)
1732 elapsed_ms = (time.perf_counter() - start) * 1000
1733
1734 assert read_commit(repo, c.commit_id) is not None
1735 assert elapsed_ms < 5, (
1736 f"write_commit took {elapsed_ms:.2f} ms — exceeds the 5 ms fsync budget. "
1737 "Performance regression in the atomic write path."
1738 )
1739
1740 @pytest.mark.perf
1741 def test_write_object_4kib_under_5ms(self, tmp_path: pathlib.Path) -> None:
1742 """Single 4 KiB object write (bytes + fsync + rename) < 5 ms."""
1743 import time
1744 repo = _repo(tmp_path)
1745 data = os.urandom(4096)
1746 oid = _oid(data)
1747
1748 start = time.perf_counter()
1749 write_object(repo, oid, data)
1750 elapsed_ms = (time.perf_counter() - start) * 1000
1751
1752 assert read_object(repo, oid) == data
1753 assert elapsed_ms < 5, (
1754 f"write_object took {elapsed_ms:.2f} ms — exceeds the 5 ms fsync budget."
1755 )
1756
1757 @pytest.mark.perf
1758 def test_write_text_atomic_4kib_under_5ms(self, tmp_path: pathlib.Path) -> None:
1759 """write_text_atomic on a 4 KiB text blob < 5 ms."""
1760 import time
1761 path = tmp_path / "state.txt"
1762 text = "x" * 4096
1763
1764 start = time.perf_counter()
1765 write_text_atomic(path, text)
1766 elapsed_ms = (time.perf_counter() - start) * 1000
1767
1768 assert path.read_text() == text
1769 assert elapsed_ms < 5, (
1770 f"write_text_atomic took {elapsed_ms:.2f} ms — exceeds the 5 ms budget."
1771 )
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago