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
+12
View File
@@ -303,3 +303,15 @@ both initial processing and regeneration. With `None`, Ollama receives no
Configured glossary aliases are diagnostic metadata only. Meeting Context supplies Configured glossary aliases are diagnostic metadata only. Meeting Context supplies
terminology guidance; direct-protocol transcript text is never alias-substituted. terminology guidance; direct-protocol transcript text is never alias-substituted.
See `docs/protocol-generation-regression.md` for the frozen-input regression. See `docs/protocol-generation-regression.md` for the frozen-input regression.
Successful protocols are retained in `protocol/generations/NNN/` with their exact
prompt, transcript input, response, model/runtime metadata and Meeting Context.
Relative compatibility symlinks resolve through `protocol/current`, which is
replaced atomically only after a complete record is written. Failed regeneration
keeps the previous protocol, diagnostics and context. Legacy regular files are
snapshotted before conversion. Publication is serialized with a local file lock.
Read `current` once when inspecting a consistent multi-file snapshot. Copy whole
run directories preserving relative symlinks. Process interruption may leave an
unreferenced record or temporary directory, never a partial current generation.
This local POSIX-filesystem contract does not promise power-loss durability or
network-filesystem transaction semantics.
+12
View File
@@ -278,3 +278,15 @@ Artefakten, Windows-Beispiel und Vergleich mit dem AI-PC stehen in
## Lizenz ## Lizenz
Noch nicht festgelegt. Noch nicht festgelegt.
Successful protocols are retained in `protocol/generations/NNN/` with their exact
prompt, transcript input, response, model/runtime metadata and Meeting Context.
Relative compatibility symlinks resolve through `protocol/current`, which is
replaced atomically only after a complete record is written. Failed regeneration
keeps the previous protocol, diagnostics and context. Legacy regular files are
snapshotted before conversion. Publication is serialized with a local file lock.
Read `current` once when inspecting a consistent multi-file snapshot. Copy whole
run directories preserving relative symlinks. Process interruption may leave an
unreferenced record or temporary directory, never a partial current generation.
This local POSIX-filesystem contract does not promise power-loss durability or
network-filesystem transaction semantics.
+7 -18
View File
@@ -17,11 +17,12 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
from src.meeting_lab.models.meeting_context import load_meeting_context # noqa: E402
from src.meeting_lab.protocol.history import persist_protocol_generation # noqa: E402
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402 from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402
from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402 from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_SAFE_INPUT_TOKEN_BUDGET, DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
DirectProtocolResult,
generate_direct_protocol, generate_direct_protocol,
) )
@@ -64,22 +65,6 @@ def write_json(path: Path, data: Any) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def persist_result(run_dir: Path, result: DirectProtocolResult) -> Path:
protocol_dir = run_dir / "protocol"
protocol_dir.mkdir()
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
write_json(protocol_dir / "raw_response.json", result.raw_response)
write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
transcript_input = getattr(result, "transcript_input", None)
if transcript_input is not None:
(protocol_dir / "transcript_input.txt").write_text(
transcript_input, encoding="utf-8"
)
protocol_path = run_dir / "protocol.md"
protocol_path.write_text(result.protocol_text, encoding="utf-8")
return protocol_path
def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]: def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]:
run_dir = create_unique_run_dir(args.output_root, args.transcript.stem) run_dir = create_unique_run_dir(args.output_root, args.transcript.stem)
timestamp = datetime.now().astimezone().isoformat(timespec="seconds") timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
@@ -129,7 +114,11 @@ def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]:
endpoint=args.ollama_endpoint, endpoint=args.ollama_endpoint,
safe_input_token_budget=args.safe_input_token_budget, safe_input_token_budget=args.safe_input_token_budget,
) )
protocol_path = persist_result(run_dir, result) protocol_path = persist_protocol_generation(
run_dir,
result,
context=load_meeting_context(preserved_context) if preserved_context else None,
)
metadata["status"] = "completed" metadata["status"] = "completed"
metadata["final_protocol_path"] = str(protocol_path.resolve()) metadata["final_protocol_path"] = str(protocol_path.resolve())
except Exception as exc: except Exception as exc:
+9 -21
View File
@@ -6,6 +6,7 @@ import json
import re import re
import shutil import shutil
import sys import sys
import tempfile
import time import time
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
@@ -28,11 +29,11 @@ from src.meeting_lab.models.meeting_context import (
write_meeting_context, write_meeting_context,
) )
from src.meeting_lab.progress import ProgressEvent, ProgressSink, ProgressStatus from src.meeting_lab.progress import ProgressEvent, ProgressSink, ProgressStatus
from src.meeting_lab.protocol.history import persist_protocol_generation
from src.meeting_lab.protocol.generate_direct_protocol import ( from src.meeting_lab.protocol.generate_direct_protocol import (
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_NUM_CTX, DEFAULT_NUM_CTX,
DEFAULT_SAFE_INPUT_TOKEN_BUDGET, DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
DirectProtocolResult,
generate_direct_protocol, generate_direct_protocol,
load_compact_transcript, load_compact_transcript,
) )
@@ -110,10 +111,13 @@ def regenerate_mvp_protocol(
f"Existing run has no protocol transcript artifact: {run_dir}" f"Existing run has no protocol transcript artifact: {run_dir}"
) )
context_path = run_dir / "context" / "meeting_context.yaml"
write_meeting_context(context, context_path)
_emit(progress_sink, "protocol_generation", "started", started) _emit(progress_sink, "protocol_generation", "started", started)
try: try:
with tempfile.TemporaryDirectory(
prefix=".protocol-context-", dir=run_dir, ignore_cleanup_errors=True
) as temporary:
context_path = Path(temporary) / "meeting_context.yaml"
write_meeting_context(context, context_path)
result = generate_direct_protocol( result = generate_direct_protocol(
transcript_path, transcript_path,
context_path, context_path,
@@ -124,7 +128,7 @@ def regenerate_mvp_protocol(
glossary_aliases=glossary_aliases, glossary_aliases=glossary_aliases,
safe_input_token_budget=protocol_safe_input_token_budget, safe_input_token_budget=protocol_safe_input_token_budget,
) )
protocol_path = _persist_protocol(run_dir, result) protocol_path = persist_protocol_generation(run_dir, result, context=context)
except Exception as exc: except Exception as exc:
_emit( _emit(
progress_sink, progress_sink,
@@ -228,22 +232,6 @@ def _emit(
) )
def _persist_protocol(run_dir: Path, result: DirectProtocolResult) -> Path:
protocol_dir = run_dir / "protocol"
protocol_dir.mkdir(exist_ok=True)
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
_write_json(protocol_dir / "raw_response.json", result.raw_response)
_write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
transcript_input = getattr(result, "transcript_input", None)
if transcript_input is not None:
(protocol_dir / "transcript_input.txt").write_text(
transcript_input, encoding="utf-8"
)
protocol_path = run_dir / "protocol.md"
protocol_path.write_text(result.protocol_text, encoding="utf-8")
return protocol_path
def run_mvp_meeting( def run_mvp_meeting(
config: MvpMeetingConfig, config: MvpMeetingConfig,
*, *,
@@ -445,7 +433,7 @@ def run_mvp_meeting(
safe_input_token_budget=config.protocol_safe_input_token_budget, safe_input_token_budget=config.protocol_safe_input_token_budget,
) )
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3) stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
protocol_path = _persist_protocol(run_dir, result) protocol_path = persist_protocol_generation(run_dir, result, context=effective_context)
_emit(progress_sink, "protocol_generation", "completed", overall_started) _emit(progress_sink, "protocol_generation", "completed", overall_started)
metadata["status"] = "completed" metadata["status"] = "completed"
_emit(progress_sink, "completed", "completed", overall_started) _emit(progress_sink, "completed", "completed", overall_started)
+188
View File
@@ -0,0 +1,188 @@
"""Immutable protocol records with one atomic latest-generation publication."""
from __future__ import annotations
import fcntl
import json
import os
import shutil
import subprocess
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from src.meeting_lab.models.meeting_context import MeetingContext, write_meeting_context
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
_DIAGNOSTICS = (
"exact_prompt.txt",
"transcript_input.txt",
"raw_response.json",
"runtime_metadata.json",
)
def _git_provenance(repository: Path) -> dict[str, Any]:
provenance: dict[str, Any] = {"repository": str(repository), "sha": None, "dirty": None}
try:
provenance["sha"] = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=repository, text=True, stderr=subprocess.DEVNULL
).strip()
provenance["dirty"] = bool(
subprocess.check_output(
["git", "status", "--porcelain"],
cwd=repository,
text=True,
stderr=subprocess.DEVNULL,
)
)
except (OSError, subprocess.SubprocessError):
pass
return provenance
def _write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _replace_link(path: Path, target: str) -> None:
"""Replace a link/file atomically; never write through an existing symlink."""
temporary = path.with_name(f".{path.name}-{uuid4().hex}")
try:
temporary.symlink_to(target)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _sync_record(directory: Path) -> None:
for path in directory.iterdir():
with path.open("rb") as stream:
os.fsync(stream.fileno())
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _legacy_generation(run_dir: Path, protocol_dir: Path, generations: Path) -> None:
"""Bootstrap old regular files before switching any compatibility paths."""
if (protocol_dir / "current").is_symlink() or not (run_dir / "protocol.md").is_file():
return
# Preserve the latest regular-file state even for runs created by an older
# history implementation. Never reuse or overwrite an existing record.
index = max(
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
default=0,
) + 1
legacy = generations / f"{index:03d}"
pending = Path(tempfile.mkdtemp(prefix=".legacy-", dir=generations))
try:
shutil.copyfile(run_dir / "protocol.md", pending / "protocol.md")
for name in _DIAGNOSTICS:
source = protocol_dir / name
if source.is_file():
shutil.copyfile(source, pending / name)
context = run_dir / "context/meeting_context.yaml"
if context.is_file():
shutil.copyfile(context, pending / "meeting_context.yaml")
_sync_record(pending)
pending.rename(legacy)
finally:
shutil.rmtree(pending, ignore_errors=True)
_replace_link(protocol_dir / "current", f"generations/{legacy.name}")
def _compatibility_links(run_dir: Path, protocol_dir: Path, pending: Path) -> None:
"""Install indirections while they still resolve to the previous generation."""
links = [(run_dir / "protocol.md", "protocol/current/protocol.md")]
links.extend((protocol_dir / name, f"current/{name}") for name in _DIAGNOSTICS)
if (pending / "meeting_context.yaml").is_file():
context_dir = run_dir / "context"
context_dir.mkdir(exist_ok=True)
links.append(
(context_dir / "meeting_context.yaml", "../protocol/current/meeting_context.yaml")
)
for path, target in links:
if not path.is_symlink() or os.readlink(path) != target:
_replace_link(path, target)
def persist_protocol_generation(
run_dir: Path,
result: DirectProtocolResult,
*,
context: MeetingContext | None,
) -> Path:
"""Publish a complete record; current is the sole successful-generation pointer.
Readers needing a multi-file snapshot should resolve current once. Relative
symlinks keep complete run directories movable. Writes are serialized locally.
"""
protocol_dir = run_dir / "protocol"
protocol_dir.mkdir(exist_ok=True)
generations = protocol_dir / "generations"
generations.mkdir(exist_ok=True)
with (protocol_dir / ".publication.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
_legacy_generation(run_dir, protocol_dir, generations)
index = (
max(
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
default=0,
)
+ 1
)
destination = generations / f"{index:03d}"
pending = Path(tempfile.mkdtemp(prefix=".pending-", dir=generations))
published = False
try:
metadata = dict(result.runtime_metadata)
metadata.update(
{
"generation_index": index,
"generation_timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
"source_run_id": run_dir.name,
"speaker_mapping": dict(sorted(context.speaker_mappings.items()))
if context
else {},
"speaker_mapping_names": {
label: context.participant_for_speaker(label).get("display_name", "")
for label in sorted(context.speaker_mappings)
if context.participant_for_speaker(label) is not None
}
if context
else {},
"git": {"meeting_lab": _git_provenance(Path(__file__).resolve().parents[3])},
}
)
(pending / "protocol.md").write_text(result.protocol_text, encoding="utf-8")
(pending / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
if result.transcript_input is not None:
(pending / "transcript_input.txt").write_text(
result.transcript_input, encoding="utf-8"
)
_write_json(pending / "raw_response.json", result.raw_response)
_write_json(pending / "runtime_metadata.json", metadata)
_write_json(pending / "model_metadata.json", result.model_metadata)
if context is not None:
write_meeting_context(context, pending / "meeting_context.yaml")
_sync_record(pending)
_compatibility_links(run_dir, protocol_dir, pending)
pending.rename(destination)
_replace_link(protocol_dir / "current", f"generations/{destination.name}")
published = True
finally:
shutil.rmtree(pending, ignore_errors=True)
# An interrupted process may leave an unreferenced complete record;
# never delete the record selected by current, even after interruption.
if (
not published
and destination.exists()
and (protocol_dir / "current").resolve() != destination
):
shutil.rmtree(destination, ignore_errors=True)
return run_dir / "protocol.md"
+3
View File
@@ -254,6 +254,7 @@ class DirectProtocolCliTests(unittest.TestCase):
"transcript_input": "selected transcript\n", "transcript_input": "selected transcript\n",
"raw_response": {"response": protocol_text}, "raw_response": {"response": protocol_text},
"runtime_metadata": {"request_count": 1}, "runtime_metadata": {"request_count": 1},
"model_metadata": {},
})(), })(),
) as generator: ) as generator:
code, run_dir, protocol_path = run_direct_protocol.run(args) code, run_dir, protocol_path = run_direct_protocol.run(args)
@@ -321,6 +322,8 @@ class DirectProtocolCliTests(unittest.TestCase):
"exact_prompt": "prompt", "exact_prompt": "prompt",
"raw_response": {"response": "# Meeting Protocol"}, "raw_response": {"response": "# Meeting Protocol"},
"runtime_metadata": {}, "runtime_metadata": {},
"model_metadata": {},
"transcript_input": None,
})() })()
with ( with (
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction, patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
+1
View File
@@ -118,6 +118,7 @@ class MvpApiTests(unittest.TestCase):
patch.object( patch.object(
mvp_api, "generate_direct_protocol", side_effect=fake_protocol mvp_api, "generate_direct_protocol", side_effect=fake_protocol
) as protocol_generator, ) as protocol_generator,
patch("src.meeting_lab.protocol.history._git_provenance", return_value={}),
patch.object(subprocess, "run") as subprocess_run, patch.object(subprocess, "run") as subprocess_run,
): ):
result = mvp_api.run_mvp_meeting( result = mvp_api.run_mvp_meeting(
+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)