Source code for ralph.workspace.agent_dir_retention

"""Run-start retention sweep for machine-only ``.agent`` bookkeeping.

Long-lived workspaces accumulate one ``completion_seen_<run_id>.json``
per agent session, one ``receipts/<run_id>/`` directory per run, and
``agent_retry_*`` scratch per retry — hundreds of files over multi-day
runs. Nothing reads them after their run ends. The sweep deletes
entries older than ``max_age_seconds`` (default 7 days), always keeping
the current run's entries regardless of age.

Everything here is best-effort: a failed unlink is skipped, never raised,
so a permission quirk cannot break run startup. The DB prune (RFC-013
P3) is invoked with the same best-effort contract.
"""

from __future__ import annotations

import contextlib
import shutil
import sqlite3
import time
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path

from ralph.mcp.artifacts.state_db import DB_RELPATH, RunStateDB

DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 3600.0

_SCRATCH_GLOBS: tuple[str, ...] = (
    "agent_retry_*.md",
    "agent_retry_context_*.md",
)


def _older_than(path: Path, cutoff: float) -> bool:
    """True when *path*'s mtime (or any contained file for a directory) is older than *cutoff*."""
    try:
        if path.is_dir():
            try:
                newest_mtime = path.stat().st_mtime
            except OSError:
                newest_mtime = 0.0
            for child in path.iterdir():
                try:
                    child_mtime = child.stat().st_mtime
                except OSError:
                    continue
                newest_mtime = max(newest_mtime, child_mtime)
            return newest_mtime < cutoff
        return path.stat().st_mtime < cutoff
    except OSError:
        return False


def _sweep_completion_sentinels(
    agent_dir: Path,
    *,
    cutoff: float,
    keep_sentinel: str | None,
) -> int:
    """Remove aged completion sentinel JSON files (never raises)."""
    removed = 0
    for sentinel in agent_dir.glob("completion_seen_*.json"):
        if sentinel.name == keep_sentinel or not _older_than(sentinel, cutoff):
            continue
        try:
            sentinel.unlink()
            removed += 1
        except OSError:
            continue
    return removed


def _sweep_receipt_dirs(
    receipts_dir: Path,
    *,
    cutoff: float,
    keep_run_id: str | None,
) -> int:
    """Remove aged per-run receipt directories (never raises)."""
    if not receipts_dir.is_dir():
        return 0
    removed = 0
    for run_dir in receipts_dir.iterdir():
        if not run_dir.is_dir() or run_dir.name == keep_run_id:
            continue
        if not _older_than(run_dir, cutoff):
            continue
        try:
            shutil.rmtree(run_dir)
            removed += 1
        except OSError:
            continue
    return removed


def _sweep_scratch_files(tmp_dir: Path, *, cutoff: float) -> int:
    """Remove aged ``agent_retry_*`` scratch files (never raises)."""
    if not tmp_dir.is_dir():
        return 0
    removed = 0
    for pattern in _SCRATCH_GLOBS:
        for scratch in tmp_dir.glob(pattern):
            if not _older_than(scratch, cutoff):
                continue
            try:
                scratch.unlink()
                removed += 1
            except OSError:
                continue
    return removed


def _sweep_run_state_db_rows(
    workspace_root: Path,
    *,
    cutoff: float,
    keep_run_id: str | None,
) -> int:
    """RFC-013 P3: prune aged rows in ``.agent/state.db`` (never raises).

    Mirrors the file-path ``keep_run_id`` contract: when ``keep_run_id``
    is provided, rows for that run are preserved regardless of age so
    the DB-backed retention behavior does not regress the in-flight
    run's own receipts and sentinels.

    Side-effect free: when ``.agent/state.db`` is absent the sweep
    does NOT create one. ``RunStateDB.__init__`` creates the database
    on open, so this helper short-circuits on absence to avoid
    turning the cleanup path into a state.db-creation path.
    """
    db_path = workspace_root / DB_RELPATH
    if not db_path.exists():
        return 0
    try:
        db = RunStateDB(workspace_root)
    except (OSError, RuntimeError, sqlite3.Error):
        return 0
    try:
        try:
            return db.prune_older_than(cutoff, keep_run_id=keep_run_id)
        except (OSError, RuntimeError, sqlite3.Error):
            return 0
    finally:
        with contextlib.suppress(OSError, RuntimeError, sqlite3.Error):
            db.close()


[docs] def sweep_agent_dir( workspace_root: Path, *, keep_run_id: str | None, max_age_seconds: float = DEFAULT_MAX_AGE_SECONDS, now: Callable[[], float] = time.time, ) -> int: """Delete aged machine-only bookkeeping under ``<workspace>/.agent``. The file-glob sweep covers ``completion_seen_*.json``, ``receipts/``, and ``tmp/agent_retry_*.md``. When ``.agent/state.db`` is present (RFC-013 P3) the sweep also calls ``RunStateDB.prune_older_than`` so aged DB rows do not accumulate either. Both passes are best-effort. Args: workspace_root: Workspace root containing ``.agent``. keep_run_id: Current run id whose sentinel/receipts are always kept. max_age_seconds: Entries younger than this are kept. now: Clock injection for tests. Returns: Number of filesystem entries removed (file count + DB row count). """ agent_dir = workspace_root / ".agent" if not agent_dir.is_dir(): return 0 cutoff = now() - max_age_seconds keep_sentinel = ( f"completion_seen_{keep_run_id}.json" if keep_run_id is not None else None ) removed = _sweep_completion_sentinels( agent_dir, cutoff=cutoff, keep_sentinel=keep_sentinel, ) removed += _sweep_receipt_dirs( agent_dir / "receipts", cutoff=cutoff, keep_run_id=keep_run_id, ) removed += _sweep_scratch_files(agent_dir / "tmp", cutoff=cutoff) removed += _sweep_run_state_db_rows( workspace_root, cutoff=cutoff, keep_run_id=keep_run_id ) return removed
__all__ = ["DEFAULT_MAX_AGE_SECONDS", "sweep_agent_dir"]