Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

183 lines
6.5 KiB
Python

"""Startup orphan / stale-lockfile sweep for the local agent registry.
Run once at REPL boot. Idempotent — running twice in a row is a no-op
the second time. Removes ``AgentRegistry`` entries whose PIDs no longer
exist plus lockfiles in ``~/.opensre/agents/`` that correspond
to dead PIDs.
Liveness is checked via ``pid_exists`` rather than ``probe()``
because ``probe()`` returns ``None`` for two distinct reasons —
"PID doesn't exist" and "PID exists but I can't access its fields"
(``psutil.AccessDenied``, common for cross-user processes on macOS
or with a hardened ``/proc``). Treating both cases as "dead" would
silently delete records and lockfiles for live processes owned by
other users. ``pid_exists`` is the right primitive: it does an
OS-level existence check that doesn't traverse the access boundary.
The function is split from the boot wiring so it stays unit-testable
without spinning up the REPL: the loop.py side just calls
``run_startup_sweep()`` and lets this module handle path defaults and
error suppression.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from config.constants import OPENSRE_HOME_DIR
from tools.system.fleet_monitoring.registry import AgentRecord, AgentRegistry
logger = logging.getLogger(__name__)
#: Default location of the per-PID lockfile directory.
DEFAULT_LOCK_DIR: Path = OPENSRE_HOME_DIR / "agents"
def pid_exists(pid: int) -> bool:
"""Return PID liveness, defaulting to live when psutil is unavailable."""
try:
from tools.system.fleet_monitoring.probe import pid_exists as probe_pid_exists
except ModuleNotFoundError as exc:
if exc.name != "psutil":
raise
logger.debug("psutil is unavailable; skipping stale agent sweep")
return True
return probe_pid_exists(pid)
@dataclass(frozen=True)
class SweepResult:
"""What a single ``sweep()`` invocation removed.
Empty tuples on an already-clean run; an already-pruned registry
paired with no stale lockfiles produces ``SweepResult()`` — that's
the contract idempotency rests on.
"""
removed_records: tuple[AgentRecord, ...] = field(default_factory=tuple)
removed_locks: tuple[Path, ...] = field(default_factory=tuple)
@property
def total(self) -> int:
"""Sum of removed records and lockfiles. Useful for log messages."""
return len(self.removed_records) + len(self.removed_locks)
def sweep(
registry: AgentRegistry,
lock_dir: Path = DEFAULT_LOCK_DIR,
) -> SweepResult:
"""Remove dead-PID registry entries and stale lockfiles.
Parameters
----------
registry
The ``AgentRegistry`` to prune. Mutated in place.
lock_dir
Directory containing per-PID lockfiles named ``<pid>.lock``.
Missing directories are tolerated (returned with empty
``removed_locks``); files whose stems aren't integer PIDs are
left untouched so the sweep can't accidentally delete unrelated
artifacts.
Returns
-------
SweepResult
Lists of what was removed. Empty after an already-clean run.
"""
removed_records = _sweep_registry(registry)
removed_locks = _sweep_locks(lock_dir)
return SweepResult(
removed_records=tuple(removed_records),
removed_locks=tuple(removed_locks),
)
def run_startup_sweep() -> SweepResult:
"""Convenience wrapper for the REPL boot path.
Constructs an ``AgentRegistry`` at the default location, runs
``sweep()`` against the default lockfile dir, and swallows any
exception so a sweep failure never prevents the REPL from
starting. Returns an empty ``SweepResult`` on error.
"""
try:
registry = AgentRegistry()
result = sweep(registry)
except Exception:
# Pinned by ``test_run_startup_sweep_swallows_exceptions`` which
# mocks ``AgentRegistry()`` to raise. The REPL must boot even if
# the sweep is broken; logging is the only side effect.
logger.warning("agent sweep failed at REPL boot", exc_info=True)
return SweepResult()
if result.total > 0:
logger.debug(
"agent sweep removed %d records and %d lockfiles",
len(result.removed_records),
len(result.removed_locks),
)
return result
def _sweep_registry(registry: AgentRegistry) -> list[AgentRecord]:
"""Prune dead-PID records in a single batched rewrite.
Building the dead-PID set first and calling ``forget_many`` once
means a registry with N dead entries triggers exactly one
``_rewrite()`` instead of N — relevant if a developer crashes and
relaunches the REPL many times without registry maintenance. See
AgentRegistry.forget_many for the contract.
"""
dead_pids = [record.pid for record in registry.list() if not pid_exists(record.pid)]
removed = registry.forget_many(dead_pids)
for record in removed:
logger.debug(
"sweep: forgot dead agent record pid=%s name=%s",
record.pid,
record.name,
)
return removed
def _sweep_locks(lock_dir: Path) -> list[Path]:
removed: list[Path] = []
if not lock_dir.is_dir():
return removed
for path in sorted(lock_dir.glob("*.lock")):
# Filename convention: ``<pid>.lock``. Anything whose stem
# isn't a valid PID is ignored — we don't know what produced
# it, and a future naming convention shouldn't trigger
# spurious removals.
try:
pid = int(path.stem)
except ValueError:
continue
if pid_exists(pid):
continue
try:
path.unlink()
except FileNotFoundError:
# Race against another sweep / external cleanup that
# already removed this exact lockfile. Idempotent
# success — don't log a warning, don't claim we did it.
continue
except OSError:
# Read-only filesystem, permission denied, etc. Log with
# ``exc_info`` so a downstream operator can tell exactly
# why; carry on with the rest of the sweep.
logger.warning(
"sweep: failed to remove stale lockfile %s (pid=%s)",
path,
pid,
exc_info=True,
)
continue
removed.append(path)
logger.debug("sweep: removed stale lockfile %s (pid=%s)", path, pid)
return removed
__all__ = ["DEFAULT_LOCK_DIR", "SweepResult", "run_startup_sweep", "sweep"]