2026-08-16 21:55:28 -03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Bounded age, count, and byte retention for non-pending lane artifacts."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import stat
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from cli_lane_config import (
|
|
|
|
|
ARTIFACT_GC_INTERVAL_SECONDS,
|
|
|
|
|
ARTIFACT_RETENTION_AGE_SECONDS,
|
|
|
|
|
ARTIFACT_RETENTION_BYTES,
|
|
|
|
|
ARTIFACT_RETENTION_COUNT,
|
|
|
|
|
STATE_ROOT,
|
|
|
|
|
)
|
|
|
|
|
from cli_lane_evidence import _retire_terminal_entry, _terminal_evidence_valid
|
|
|
|
|
from cli_lane_files import (
|
|
|
|
|
_fsync_directory,
|
|
|
|
|
_terminal_evidence_identity,
|
|
|
|
|
_terminal_identity,
|
|
|
|
|
)
|
|
|
|
|
from cli_lane_quarantine import _quarantine_terminal
|
|
|
|
|
from cli_lane_recovery import _staged_terminal_authority
|
|
|
|
|
from cli_lane_records import _open_terminal_recovery_snapshot, _terminal_record_valid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
|
|
|
|
|
"""List only bounded artifact classes; pending journals are excluded."""
|
|
|
|
|
candidates: list[Path] = []
|
|
|
|
|
patterns = (
|
|
|
|
|
"*.provider-*.result.json",
|
|
|
|
|
"*.candidate-*.json",
|
|
|
|
|
"*.terminal.committed.json",
|
|
|
|
|
"*.terminal.conflict-*.json",
|
|
|
|
|
".retire.*",
|
|
|
|
|
)
|
|
|
|
|
for pattern in patterns:
|
|
|
|
|
candidates.extend(board_dir.glob(pattern))
|
|
|
|
|
quarantine = board_dir / "quarantine"
|
|
|
|
|
if quarantine.is_dir():
|
|
|
|
|
candidates.extend(quarantine.glob("*.quarantine"))
|
|
|
|
|
candidates.extend(quarantine.glob(".retire.*"))
|
|
|
|
|
return candidates
|
|
|
|
|
|
|
|
|
|
def _unlink_artifact_if_same(path: Path, observed: os.stat_result) -> bool:
|
|
|
|
|
"""Remove only the exact artifact inode selected by retention."""
|
|
|
|
|
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
|
|
|
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
|
|
|
descriptor = None
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor = None
|
2026-08-16 21:55:28 -03:00
|
|
|
try:
|
|
|
|
|
descriptor = os.open(path.parent, directory_flags)
|
2026-08-17 16:31:15 +00:00
|
|
|
file_flags = getattr(os, "O_PATH", os.O_RDONLY) | getattr(os, "O_NOFOLLOW", 0)
|
|
|
|
|
source_descriptor = os.open(path.name, file_flags, dir_fd=descriptor)
|
|
|
|
|
pinned = os.fstat(source_descriptor)
|
|
|
|
|
if pinned.st_dev != observed.st_dev or pinned.st_ino != observed.st_ino:
|
|
|
|
|
return False
|
2026-08-16 21:55:28 -03:00
|
|
|
outcome = _retire_terminal_entry(
|
|
|
|
|
path,
|
|
|
|
|
observed,
|
|
|
|
|
board_descriptor=descriptor,
|
|
|
|
|
quarantine_descriptor=descriptor,
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor=source_descriptor,
|
2026-08-16 21:55:28 -03:00
|
|
|
)
|
|
|
|
|
return outcome == "retired"
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
finally:
|
2026-08-17 16:31:15 +00:00
|
|
|
if source_descriptor is not None:
|
|
|
|
|
os.close(source_descriptor)
|
2026-08-16 21:55:28 -03:00
|
|
|
if descriptor is not None:
|
|
|
|
|
os.close(descriptor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _quarantine_invalid_retained_terminal(path: Path) -> tuple[bool, bool]:
|
|
|
|
|
"""Audit retained committed/conflict evidence on the bounded GC cadence."""
|
|
|
|
|
committed = path.name.endswith(".terminal.committed.json")
|
|
|
|
|
conflict = ".terminal.conflict-" in path.name and path.name.endswith(".json")
|
|
|
|
|
if not committed and not conflict:
|
|
|
|
|
return False, False
|
|
|
|
|
identity = (
|
|
|
|
|
_terminal_identity(path) if committed else _terminal_evidence_identity(path)
|
|
|
|
|
)
|
|
|
|
|
snapshot = _open_terminal_recovery_snapshot(path)
|
|
|
|
|
if snapshot is None:
|
|
|
|
|
return True, False
|
|
|
|
|
document = snapshot.document
|
|
|
|
|
valid = False
|
|
|
|
|
if identity is not None and document is not None:
|
|
|
|
|
valid = (
|
|
|
|
|
_terminal_record_valid(document, identity)
|
|
|
|
|
if committed
|
|
|
|
|
else _terminal_evidence_valid(document, identity, "conflict")
|
|
|
|
|
)
|
|
|
|
|
if valid:
|
|
|
|
|
snapshot.close()
|
|
|
|
|
return False, False
|
|
|
|
|
try:
|
|
|
|
|
_quarantine_terminal(
|
|
|
|
|
path,
|
|
|
|
|
identity,
|
|
|
|
|
snapshot.invalid_reason or "invalid-retained-terminal-evidence",
|
|
|
|
|
snapshot=snapshot,
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
snapshot.close()
|
|
|
|
|
try:
|
|
|
|
|
path.stat(follow_symlinks=False)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return True, True
|
|
|
|
|
except OSError:
|
|
|
|
|
return True, False
|
|
|
|
|
return True, False
|
|
|
|
|
|
|
|
|
|
def gc_lane_artifacts(
|
|
|
|
|
*,
|
|
|
|
|
now: float | None = None,
|
|
|
|
|
max_age_seconds: int = ARTIFACT_RETENTION_AGE_SECONDS,
|
|
|
|
|
max_count: int = ARTIFACT_RETENTION_COUNT,
|
|
|
|
|
max_bytes: int = ARTIFACT_RETENTION_BYTES,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Bound non-pending lane artifacts by age, count, and bytes per board."""
|
|
|
|
|
current = time.time() if now is None else now
|
|
|
|
|
removed = 0
|
|
|
|
|
for board_dir in STATE_ROOT.iterdir() if STATE_ROOT.is_dir() else ():
|
|
|
|
|
if not board_dir.is_dir() or board_dir.is_symlink():
|
|
|
|
|
continue
|
|
|
|
|
board_removed = 0
|
|
|
|
|
entries: list[tuple[Path, os.stat_result]] = []
|
|
|
|
|
for path in _artifact_gc_candidates(board_dir):
|
|
|
|
|
classified, quarantined = _quarantine_invalid_retained_terminal(path)
|
|
|
|
|
if classified:
|
|
|
|
|
if quarantined:
|
|
|
|
|
removed += 1
|
|
|
|
|
board_removed += 1
|
|
|
|
|
continue
|
|
|
|
|
if path.name.startswith(".retire."):
|
|
|
|
|
snapshot = _open_terminal_recovery_snapshot(path)
|
|
|
|
|
if snapshot is not None:
|
|
|
|
|
try:
|
|
|
|
|
if _staged_terminal_authority(path, snapshot) is not None:
|
|
|
|
|
# Accepted authority is first promoted/replayed by
|
|
|
|
|
# recovery; retention never deletes it directly.
|
|
|
|
|
continue
|
|
|
|
|
finally:
|
|
|
|
|
snapshot.close()
|
|
|
|
|
try:
|
|
|
|
|
file_stat = path.stat(follow_symlinks=False)
|
|
|
|
|
except OSError:
|
|
|
|
|
continue
|
|
|
|
|
if not stat.S_ISREG(file_stat.st_mode):
|
|
|
|
|
if _unlink_artifact_if_same(path, file_stat):
|
|
|
|
|
removed += 1
|
|
|
|
|
board_removed += 1
|
|
|
|
|
continue
|
|
|
|
|
if max_age_seconds >= 0 and current - file_stat.st_mtime > max_age_seconds:
|
|
|
|
|
if _unlink_artifact_if_same(path, file_stat):
|
|
|
|
|
removed += 1
|
|
|
|
|
board_removed += 1
|
|
|
|
|
else:
|
|
|
|
|
entries.append((path, file_stat))
|
|
|
|
|
retained_count = 0
|
|
|
|
|
retained_bytes = 0
|
|
|
|
|
for path, file_stat in sorted(
|
|
|
|
|
entries, key=lambda item: item[1].st_mtime, reverse=True
|
|
|
|
|
):
|
|
|
|
|
exceeds_count = max_count >= 0 and retained_count >= max_count
|
|
|
|
|
exceeds_bytes = (
|
|
|
|
|
max_bytes >= 0 and retained_bytes + file_stat.st_size > max_bytes
|
|
|
|
|
)
|
|
|
|
|
if exceeds_count or exceeds_bytes:
|
|
|
|
|
if _unlink_artifact_if_same(path, file_stat):
|
|
|
|
|
removed += 1
|
|
|
|
|
board_removed += 1
|
|
|
|
|
continue
|
|
|
|
|
retained_count += 1
|
|
|
|
|
retained_bytes += file_stat.st_size
|
|
|
|
|
if board_removed:
|
|
|
|
|
_fsync_directory(board_dir)
|
|
|
|
|
return removed
|
|
|
|
|
|
|
|
|
|
LAST_ARTIFACT_GC = 0.0
|
|
|
|
|
|
|
|
|
|
def maybe_gc_lane_artifacts(*, now: float | None = None) -> int:
|
|
|
|
|
"""Run artifact retention on a bounded cadence, not every dispatcher tick."""
|
|
|
|
|
global LAST_ARTIFACT_GC
|
|
|
|
|
current = time.time() if now is None else now
|
|
|
|
|
if current - LAST_ARTIFACT_GC < ARTIFACT_GC_INTERVAL_SECONDS:
|
|
|
|
|
return 0
|
|
|
|
|
LAST_ARTIFACT_GC = current
|
|
|
|
|
try:
|
|
|
|
|
return gc_lane_artifacts(now=current)
|
|
|
|
|
except OSError as error:
|
|
|
|
|
print(
|
|
|
|
|
f"lane artifact retention deferred: {type(error).__name__}: {error}",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
flush=True,
|
|
|
|
|
)
|
|
|
|
|
return 0
|