Publish complete protocol generations atomically
This commit is contained in:
@@ -254,6 +254,7 @@ class DirectProtocolCliTests(unittest.TestCase):
|
||||
"transcript_input": "selected transcript\n",
|
||||
"raw_response": {"response": protocol_text},
|
||||
"runtime_metadata": {"request_count": 1},
|
||||
"model_metadata": {},
|
||||
})(),
|
||||
) as generator:
|
||||
code, run_dir, protocol_path = run_direct_protocol.run(args)
|
||||
@@ -321,6 +322,8 @@ class DirectProtocolCliTests(unittest.TestCase):
|
||||
"exact_prompt": "prompt",
|
||||
"raw_response": {"response": "# Meeting Protocol"},
|
||||
"runtime_metadata": {},
|
||||
"model_metadata": {},
|
||||
"transcript_input": None,
|
||||
})()
|
||||
with (
|
||||
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
|
||||
|
||||
@@ -118,6 +118,7 @@ class MvpApiTests(unittest.TestCase):
|
||||
patch.object(
|
||||
mvp_api, "generate_direct_protocol", side_effect=fake_protocol
|
||||
) as protocol_generator,
|
||||
patch("src.meeting_lab.protocol.history._git_provenance", return_value={}),
|
||||
patch.object(subprocess, "run") as subprocess_run,
|
||||
):
|
||||
result = mvp_api.run_mvp_meeting(
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user