atlas-iac/services/hermes/scripts/cli_lane_retention.py
Hermes Agent 7a55b259bf hermes: add the fenced three-node distributed execution pool
Three fenced worker Pods claim Hermes Kanban runs through a coordinator that
owns every state transition, with per-ordinal HMAC authority, a mediated
broker-only SCM path, and durable per-ordinal workspaces.

Content is the reviewed head of PR #18 (689bcb6e) with PR 16's and PR 19's
contributions removed: they were merged in only to validate co-existence and are
not prerequisites, so this branch no longer carries them as ancestors. Only PR 14
and PR 15 remain, because the broker boundary and the cli_lane_* decomposition
are load-bearing for two of the fixed P0 boundaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:31:15 +00:00

203 lines
7.2 KiB
Python

#!/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
source_descriptor = None
try:
descriptor = os.open(path.parent, directory_flags)
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
outcome = _retire_terminal_entry(
path,
observed,
board_descriptor=descriptor,
quarantine_descriptor=descriptor,
source_descriptor=source_descriptor,
)
return outcome == "retired"
except OSError:
return False
finally:
if source_descriptor is not None:
os.close(source_descriptor)
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