Publish complete protocol generations atomically

This commit is contained in:
2026-09-12 11:49:10 +02:00
parent 92247ef43e
commit d2e3636b94
8 changed files with 411 additions and 49 deletions
+169
View File
@@ -0,0 +1,169 @@
"""Publication fault injection without inference or audio processing."""
import json
import multiprocessing
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.meeting_lab.models.meeting_context import create_meeting_context, load_meeting_context
from src.meeting_lab.protocol import history
from src.meeting_lab.orchestration import mvp
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
from tests import test_mvp_api as fixtures
def result(text):
return DirectProtocolResult(
protocol_text=text,
exact_prompt="prompt " + text,
transcript_input="Lumini original",
model_metadata={"name": "test"},
runtime_metadata={
"glossary_aliases_configured": {"Lumini": "Luminy"},
"glossary_replacements": [],
"num_thread": 10,
},
raw_response={"response": text},
)
def interrupt_publication(root):
original = history._replace_link
def interrupted(path, target):
if path.name == "current":
os._exit(77)
original(path, target)
history._replace_link = interrupted
history.persist_protocol_generation(
Path(root), result("new"), context=create_meeting_context(fixtures.context_data())
)
class HistoryTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.context = create_meeting_context(fixtures.context_data())
def persist(self, text):
return history.persist_protocol_generation(self.root, result(text), context=self.context)
def snapshot(self):
names = [
"protocol.md",
"context/meeting_context.yaml",
*["protocol/" + n for n in history._DIAGNOSTICS],
]
return {name: (self.root / name).read_bytes() for name in names}
def test_success_numbering_provenance_and_immutable_context(self):
self.persist("first")
first = self.snapshot()
self.persist("second")
self.assertEqual((self.root / "protocol.md").read_text(), "second")
old = self.root / "protocol/generations/001"
self.assertEqual((old / "protocol.md").read_bytes(), first["protocol.md"])
self.assertEqual(
(old / "meeting_context.yaml").read_bytes(), first["context/meeting_context.yaml"]
)
current = (self.root / "protocol/current").resolve()
self.assertEqual(current.name, "002")
metadata = json.loads((current / "runtime_metadata.json").read_text())
self.assertEqual(metadata["generation_index"], 2)
self.assertEqual(metadata["speaker_mapping"], self.context.speaker_mappings)
self.assertEqual(metadata["glossary_aliases_configured"], {"Lumini": "Luminy"})
self.assertEqual(metadata["glossary_replacements"], [])
self.assertEqual(metadata["num_thread"], 10)
self.assertEqual(metadata["source_run_id"], self.root.name)
self.assertEqual(
json.loads((current / "model_metadata.json").read_text()), {"name": "test"}
)
self.assertIn("sha", metadata["git"]["meeting_lab"])
self.assertEqual(
load_meeting_context(current / "meeting_context.yaml").data, self.context.data
)
for name in history._DIAGNOSTICS:
self.assertEqual((self.root / "protocol" / name).resolve(), current / name)
def test_failure_writing_record_preserves_previous(self):
self.persist("first")
before = self.snapshot()
with patch.object(history, "_write_json", side_effect=OSError("disk full")):
with self.assertRaises(OSError):
self.persist("second")
self.assertEqual(self.snapshot(), before)
self.persist("third")
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
def test_failure_at_atomic_publication_preserves_all_stable_paths(self):
self.persist("first")
before = self.snapshot()
original = history.os.replace
def fail_current(source, destination):
if Path(destination).name == "current":
raise OSError("publication stopped")
original(source, destination)
with patch.object(history.os, "replace", side_effect=fail_current):
with self.assertRaises(OSError):
self.persist("second")
self.assertEqual(self.snapshot(), before)
self.assertFalse((self.root / "protocol/generations/002").exists())
def test_process_interruption_before_pointer_swap_preserves_current(self):
self.persist("first")
before = self.snapshot()
worker = multiprocessing.get_context("fork").Process(
target=interrupt_publication, args=(str(self.root),)
)
worker.start()
worker.join(timeout=10)
self.assertFalse(worker.is_alive())
self.assertEqual(worker.exitcode, 77)
self.assertEqual(self.snapshot(), before)
self.persist("third")
self.assertEqual((self.root / "protocol.md").read_text(), "third")
def test_legacy_migration_and_failure_during_link_conversion(self):
(self.root / "protocol").mkdir()
(self.root / "context").mkdir()
(self.root / "protocol.md").write_text("legacy")
for name in history._DIAGNOSTICS:
(self.root / "protocol" / name).write_text("legacy " + name)
(self.root / "context/meeting_context.yaml").write_text("legacy context")
before = self.snapshot()
original = history._replace_link
def fail_conversion(path, target):
if path.name == "raw_response.json":
raise OSError("link conversion stopped")
original(path, target)
with patch.object(history, "_replace_link", side_effect=fail_conversion):
with self.assertRaises(OSError):
self.persist("new")
self.assertEqual(self.snapshot(), before)
self.persist("new")
self.assertEqual((self.root / "protocol/generations/001/protocol.md").read_text(), "legacy")
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
def test_failed_model_does_not_change_context_or_latest_generation(self):
self.persist("first")
before = self.snapshot()
(self.root / "transcript").mkdir()
(self.root / "transcript/transcript.json").write_text('{"text":"original"}')
different = fixtures.context_data()
different["speaker_mappings"] = {}
with patch.object(
mvp, "generate_direct_protocol", side_effect=RuntimeError("model stopped")
):
with self.assertRaises(RuntimeError):
mvp.regenerate_mvp_protocol(self.root, meeting_context=different)
self.assertEqual(self.snapshot(), before)