Skip to content

[v1.0] Never prune a live run bundle based on age alone #266

Description

@codeforester

Goal

Guarantee that retention cannot delete another invocation's live diagnostics or temp files.

Background

Closed issue #62 required active bundles never to be removed. Current discovery treats any status=running bundle older than max_age_seconds as stale without a process, lease, or heartbeat check:

def _discover_run_bundles(
runs_root: Path,
*,
protected: set[Path],
max_age_seconds: float | None,
now: float,
) -> list[dict[str, Any]]:
bundles: list[dict[str, Any]] = []
try:
children = sorted(runs_root.iterdir(), key=lambda path: path.name)
except OSError:
return bundles
for child in children:
if child.name.startswith(".") or child.is_symlink() or not child.is_dir():
continue
metadata = _read_bundle_metadata(child)
if metadata is None:
# Partial startup directories are deliberately not considered
# complete. They can be diagnosed or cleaned by a consumer's
# explicit maintenance command without retention guessing.
continue
status = str(metadata.get("status", ""))
started_at = _timestamp_to_epoch(metadata.get("started_at"))
if started_at is None:
try:
started_at = child.stat().st_mtime
except OSError:
continue
age = max(0.0, now - started_at)
running = status == "running"
stale_running = running and max_age_seconds is not None and age >= max_age_seconds
if running and not stale_running:
continue
if status not in {"running", "ok", "aborted", "error"}:
continue
resolved = _safe_resolved_path(child)
try:
size = _bundle_size(child)
except OSError:
continue
retention_metadata = metadata.get("retention")
preserve = bool(metadata.get("preserve")) or (
isinstance(retention_metadata, dict) and retention_metadata.get("preserve") is True
)
bundles.append(
{
"path": child,
"resolved": resolved,
"run_id": metadata.get("run_id"),
"status": status,
"started_at": started_at,
"age": age,
"size": size,
"preserve": preserve,
"protected": resolved in protected,
}
)
. The retention pass then removes it by age:
def _apply_bundle_retention(
runs_root: Path,
bundles: list[dict[str, Any]],
*,
policy: RetentionPolicy,
protected: set[Path],
logger: logging.Logger,
now: float,
reserved_active_bundles: int,
) -> None:
del now # retained for a stable extension point in policy implementations
removable = [
bundle
for bundle in bundles
if not bool(bundle["protected"])
and not bool(bundle["preserve"])
and _safe_resolved_path(bundle["path"]) not in protected
]
removable.sort(key=lambda bundle: (float(bundle["started_at"]), str(bundle["path"])))
def remove(bundle: dict[str, Any]) -> bool:
path = Path(bundle["path"])
try:
_remove_run_bundle(runs_root, path)
except OSError as exc:
logger.warning("Could not prune run bundle '%s': %s", path, exc)
removable.remove(bundle)
return False
bundles.remove(bundle)
removable.remove(bundle)
return True
if policy.max_age_seconds is not None:
for bundle in list(removable):
if float(bundle["age"]) >= policy.max_age_seconds:
remove(bundle)
. The current regression test explicitly expects an old running bundle to be deleted:
def test_stale_running_bundle_is_recoverable_with_age_bound(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) / "runs"
root.mkdir()
stale = _bundle(root, "stale", status="running", started_at="2020-01-01T00:00:00Z")
prune_run_bundles(
root,
root / "active",
policy=RetentionPolicy(max_age_seconds=60),
logger=logging.getLogger(__name__),
now=1_600_000_000,
)
self.assertFalse(stale.exists())
.

A legitimate long-running operation can therefore lose its active run bundle when another invocation starts with a short age policy.

Scope

  • Introduce a cross-platform liveness or lease contract for running bundles.
  • Separate confirmed crash recovery from age-based terminal retention.
  • Preserve fail-closed behavior when liveness cannot be established.

Acceptance Criteria

  • A live concurrent process is never pruned by count, age, or total-size policies.
  • A crashed run becomes recoverable through a documented, testable mechanism.
  • PID reuse, host changes, inherited runs, clock skew, and unreadable metadata have safe behavior.
  • Concurrency tests use separate processes, not only threads.
  • The acceptance criterion from Implement atomic run bundles and complete-bundle retention policies #62 is restored.

Validation

Run retention, cleanup-security, signal, concurrency, and platform tests.

Non-Goals

Do not remove bounded retention for terminal bundles.

Project Fields

  • Status: Backlog
  • Priority: P1
  • Area: Security
  • Initiative: v1.0 Readiness
  • Size: M

Ownership

Metadata

Metadata

Assignees

Labels

bugSomething is not workingsecuritySecurity hardening or vulnerability work

Type

No type

Projects

Status
Backlog

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions