fix: make join approval atomic

This commit is contained in:
User
2026-07-14 15:24:06 +02:00
parent bc02213d3e
commit 333874a755
9 changed files with 314 additions and 34 deletions
+57 -13
View File
@@ -1,3 +1,5 @@
import os
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
@@ -70,6 +72,14 @@ class DecisionRecord(BaseModel):
phase: int | None = None
class DecisionInput(BaseModel):
type: DecisionType
subject: str
detail: str = ""
rationale: str = ""
retracts: int | None = None
def list_decisions(session_dir: Path) -> list[DecisionRecord]:
path = session_dir / DECISIONS_FILE
if not path.exists():
@@ -90,24 +100,58 @@ def append_decision(
rationale: str = "",
retracts: int | None = None,
) -> DecisionRecord:
return append_decisions(
session_dir,
[{
"type": type,
"subject": subject,
"detail": detail,
"rationale": rationale,
"retracts": retracts,
}],
)[0]
def append_decisions(
session_dir: Path,
decisions: list[DecisionInput | dict],
) -> list[DecisionRecord]:
"""Validate and append a decision set through one atomic file replacement."""
inputs = [DecisionInput.model_validate(decision) for decision in decisions]
if not inputs:
return []
# Fase corrente PRIMA dell'append (high-water-mark D15). Import lazy: phase.py
# importa decisions.py (ciclo). Per i marker di fase (subject "phase:N") il valore
# e' ridondante col subject; per le decisioni sostanziali con subject "a nome"
# (cte_approved, ...) e' l'unico modo per filtrarle dopo un reopen.
from tht.phase import current_phase
record = DecisionRecord(
seq=len(list_decisions(session_dir)) + 1,
ts=datetime.now(UTC),
type=type,
subject=subject,
detail=detail,
rationale=rationale,
retracts=retracts,
phase=current_phase(session_dir),
)
existing = list_decisions(session_dir)
phase = current_phase(session_dir)
records = [
DecisionRecord(
seq=len(existing) + index,
ts=datetime.now(UTC),
phase=phase,
**decision.model_dump(),
)
for index, decision in enumerate(inputs, start=1)
]
path = session_dir / DECISIONS_FILE
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as f:
f.write(record.model_dump_json() + "\n")
return record
previous = path.read_text() if path.exists() else ""
separator = "" if not previous or previous.endswith("\n") else "\n"
content = previous + separator + "".join(record.model_dump_json() + "\n" for record in records)
fd, temporary_name = tempfile.mkstemp(prefix=f".{DECISIONS_FILE}.", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(fd, "w") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
return records