coord_gc.py
python
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
| 1 | """``muse coord gc`` — collect stale coordination records. |
| 2 | |
| 3 | The coordination directory (``.muse/coordination/``) accumulates records over |
| 4 | time: expired reservations, corresponding release tombstones, heartbeat files, |
| 5 | and old intents. ``muse coord gc`` removes records that are no longer needed |
| 6 | so the coordination layer stays lean and fast. |
| 7 | |
| 8 | What gets collected |
| 9 | ------------------- |
| 10 | * **Expired reservations** — TTL exhausted (after heartbeat extension), past |
| 11 | the grace period. |
| 12 | * **Released reservations** — release tombstone present and older than the |
| 13 | grace period. |
| 14 | * **Corresponding release tombstones** — removed with their reservation. |
| 15 | * **Corresponding heartbeat files** — removed with their reservation. |
| 16 | * **Orphaned releases and heartbeats** — the reservation file is gone but the |
| 17 | tombstone or heartbeat still exists (e.g. from a prior partial GC). |
| 18 | * **Old intents** (opt-in via ``--include-intents``) — older than |
| 19 | ``--max-intent-age SECONDS`` (default: 7 days). |
| 20 | |
| 21 | What is never collected |
| 22 | ----------------------- |
| 23 | * Active reservations (not released, effective expiry in the future). |
| 24 | * Records within the grace period. |
| 25 | |
| 26 | Usage:: |
| 27 | |
| 28 | muse coord gc # dry-run to preview |
| 29 | muse coord gc --execute # actually delete files |
| 30 | muse coord gc --execute --grace-period 60 # shorter grace period |
| 31 | muse coord gc --execute --include-intents # also purge old intents |
| 32 | muse coord gc --execute --verbose # per-file detail |
| 33 | muse coord gc --format json # machine-readable output |
| 34 | muse coord gc --json # shorthand |
| 35 | |
| 36 | JSON output schema:: |
| 37 | |
| 38 | { |
| 39 | "dry_run": bool, |
| 40 | "grace_period_seconds": int, |
| 41 | "include_intents": bool, |
| 42 | "max_intent_age_seconds": int, |
| 43 | "reservations_removed": int, |
| 44 | "reservations_removed_bytes": int, |
| 45 | "releases_removed": int, |
| 46 | "releases_removed_bytes": int, |
| 47 | "heartbeats_removed": int, |
| 48 | "heartbeats_removed_bytes": int, |
| 49 | "intents_removed": int, |
| 50 | "intents_removed_bytes": int, |
| 51 | "total_removed": int, |
| 52 | "total_removed_bytes": int, |
| 53 | "removed_ids": [str, ...], |
| 54 | "elapsed_seconds": float |
| 55 | } |
| 56 | |
| 57 | Exit codes:: |
| 58 | |
| 59 | 0 — success (zero removed is still success) |
| 60 | 1 — bad arguments (--grace-period < 0 or --max-intent-age <= 0) |
| 61 | |
| 62 | Flags: |
| 63 | |
| 64 | ``--execute`` |
| 65 | Actually delete files. Without this flag the command runs in dry-run |
| 66 | mode and only reports what would be removed. Always preview first. |
| 67 | |
| 68 | ``--grace-period SECONDS`` |
| 69 | Records expired or released within the last N seconds are protected |
| 70 | (default: 300 = 5 min). Raise this on high-churn swarms. |
| 71 | |
| 72 | ``--include-intents`` |
| 73 | Also purge intents older than ``--max-intent-age``. Intents are |
| 74 | permanent audit records by default. |
| 75 | |
| 76 | ``--max-intent-age SECONDS`` |
| 77 | Age threshold for intent cleanup (default: 604 800 = 7 days). |
| 78 | |
| 79 | ``--verbose`` / ``-v`` |
| 80 | Print the UUID of every removed reservation. |
| 81 | |
| 82 | ``--json`` / ``--format json`` |
| 83 | Emit result as compact JSON on stdout. |
| 84 | """ |
| 85 | |
| 86 | from __future__ import annotations |
| 87 | |
| 88 | import argparse |
| 89 | import json |
| 90 | import sys |
| 91 | |
| 92 | from muse.core.coordination import run_coord_gc |
| 93 | from muse.core.errors import ExitCode |
| 94 | from muse.core.repo import require_repo |
| 95 | |
| 96 | |
| 97 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 98 | |
| 99 | |
| 100 | def _fmt_bytes(n: int) -> str: |
| 101 | """Return a human-readable byte count using binary units. |
| 102 | |
| 103 | Scales through B → KiB → MiB → GiB → TiB, stopping at the first |
| 104 | unit where the value is below 1024. |
| 105 | |
| 106 | Examples |
| 107 | -------- |
| 108 | ``0`` → ``"0 B"`` |
| 109 | ``1023`` → ``"1023 B"`` |
| 110 | ``1024`` → ``"1.0 KiB"`` |
| 111 | ``1_048_576`` → ``"1.0 MiB"`` |
| 112 | ``1_073_741_824`` → ``"1.0 GiB"`` |
| 113 | ``1_099_511_627_776`` → ``"1.0 TiB"`` |
| 114 | """ |
| 115 | if n < 1024: |
| 116 | return f"{n} B" |
| 117 | for unit in ("KiB", "MiB", "GiB", "TiB"): |
| 118 | n /= 1024.0 |
| 119 | if n < 1024: |
| 120 | return f"{n:.1f} {unit}" |
| 121 | return f"{n:.1f} TiB" |
| 122 | |
| 123 | |
| 124 | # ── CLI registration ────────────────────────────────────────────────────────── |
| 125 | |
| 126 | |
| 127 | def register( |
| 128 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 129 | ) -> None: |
| 130 | """Register the ``gc`` subcommand on *subparsers* (under ``muse coord``). |
| 131 | |
| 132 | Wires all flags with their defaults, choices, and help text so that |
| 133 | ``--help`` output is accurate. Sets ``func`` to :func:`run`. |
| 134 | """ |
| 135 | parser = subparsers.add_parser( |
| 136 | "gc", |
| 137 | help="Collect stale coordination records from .muse/coordination/.", |
| 138 | description=__doc__, |
| 139 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 140 | ) |
| 141 | parser.add_argument( |
| 142 | "--execute", |
| 143 | action="store_true", |
| 144 | dest="execute", |
| 145 | help=( |
| 146 | "Actually delete files. Without this flag the command runs in " |
| 147 | "dry-run mode and only reports what would be removed." |
| 148 | ), |
| 149 | ) |
| 150 | parser.add_argument( |
| 151 | "--grace-period", |
| 152 | type=int, |
| 153 | default=300, |
| 154 | dest="grace_period_seconds", |
| 155 | metavar="SECONDS", |
| 156 | help=( |
| 157 | "Records expired or released within the last N seconds are skipped " |
| 158 | "(default: 300 = 5 min). Increase this on busy swarms to avoid " |
| 159 | "racing with agents that are still reading coordination state." |
| 160 | ), |
| 161 | ) |
| 162 | parser.add_argument( |
| 163 | "--include-intents", |
| 164 | action="store_true", |
| 165 | dest="include_intents", |
| 166 | help=( |
| 167 | "Also purge intents older than --max-intent-age. Intents are " |
| 168 | "permanent audit records by default — only enable if you are " |
| 169 | "confident you no longer need them for post-mortem analysis." |
| 170 | ), |
| 171 | ) |
| 172 | parser.add_argument( |
| 173 | "--max-intent-age", |
| 174 | type=int, |
| 175 | default=604800, |
| 176 | dest="max_intent_age_seconds", |
| 177 | metavar="SECONDS", |
| 178 | help=( |
| 179 | "Age threshold for intent cleanup when --include-intents is set " |
| 180 | "(default: 604800 = 7 days)." |
| 181 | ), |
| 182 | ) |
| 183 | parser.add_argument( |
| 184 | "--verbose", "-v", |
| 185 | action="store_true", |
| 186 | help="Print the ID of every removed reservation.", |
| 187 | ) |
| 188 | parser.add_argument( |
| 189 | "--format", "-f", |
| 190 | default="text", |
| 191 | dest="fmt", |
| 192 | choices=("text", "json"), |
| 193 | help="Output format: text (default) or json.", |
| 194 | ) |
| 195 | parser.add_argument( |
| 196 | "--json", |
| 197 | action="store_const", |
| 198 | const="json", |
| 199 | dest="fmt", |
| 200 | help="Shorthand for --format json.", |
| 201 | ) |
| 202 | parser.set_defaults(func=run) |
| 203 | |
| 204 | |
| 205 | # ── Command implementation ──────────────────────────────────────────────────── |
| 206 | |
| 207 | |
| 208 | def run(args: argparse.Namespace) -> None: |
| 209 | """Remove stale coordination records from ``.muse/coordination/``. |
| 210 | |
| 211 | Execution order |
| 212 | --------------- |
| 213 | 1. **Validate inputs** — ``--grace-period`` must be >= 0; |
| 214 | ``--max-intent-age`` must be > 0. Any failure exits |
| 215 | :attr:`~muse.core.errors.ExitCode.USER_ERROR` (1) with a message to |
| 216 | *stderr* before any file I/O. |
| 217 | 2. **Run GC** — delegates to :func:`~muse.core.coordination.run_coord_gc`, |
| 218 | which loads all coordination state in a single directory scan per |
| 219 | subdirectory and deletes in O(n) passes. |
| 220 | 3. **Emit output** — compact JSON to *stdout* when ``--format json``, |
| 221 | or human-readable text otherwise. |
| 222 | |
| 223 | Dry-run mode (default) |
| 224 | ---------------------- |
| 225 | Without ``--execute`` the command reports what *would* be removed but |
| 226 | deletes nothing. This is the safe default — always preview before |
| 227 | executing. |
| 228 | |
| 229 | Grace period |
| 230 | ------------ |
| 231 | Records expired or released within the last *grace_period_seconds* are |
| 232 | skipped to protect against races with agents that are still reading |
| 233 | coordination state. The default 300 s (5 min) is appropriate for most |
| 234 | swarms. On high-churn swarms with sub-minute TTLs, lower this carefully. |
| 235 | |
| 236 | Intent cleanup (opt-in) |
| 237 | ----------------------- |
| 238 | Intents are permanent audit records by default — they are never collected |
| 239 | without ``--include-intents``. When enabled, intents older than |
| 240 | *max_intent_age_seconds* are removed. |
| 241 | |
| 242 | Security |
| 243 | -------- |
| 244 | GC only scans ``.muse/coordination/`` and its known subdirectories. No |
| 245 | user-supplied string is used to construct file paths, eliminating any |
| 246 | directory traversal risk. |
| 247 | |
| 248 | Performance |
| 249 | ----------- |
| 250 | All coordination state is loaded with a single directory scan per |
| 251 | subdirectory. Deletion is O(n) in the number of collectable records. |
| 252 | The grace-period filter is applied in memory before any deletion. |
| 253 | |
| 254 | Args: |
| 255 | args: Parsed ``argparse.Namespace`` with attributes ``execute``, |
| 256 | ``grace_period_seconds``, ``include_intents``, |
| 257 | ``max_intent_age_seconds``, ``verbose``, and ``fmt``. |
| 258 | |
| 259 | Exit codes: |
| 260 | 0 — success (zero removed is also success). |
| 261 | 1 — bad arguments. |
| 262 | """ |
| 263 | execute: bool = args.execute |
| 264 | grace_period_seconds: int = args.grace_period_seconds |
| 265 | include_intents: bool = args.include_intents |
| 266 | max_intent_age_seconds: int = args.max_intent_age_seconds |
| 267 | verbose: bool = args.verbose |
| 268 | fmt: str = args.fmt |
| 269 | as_json = fmt == "json" |
| 270 | |
| 271 | # ── Input validation (before any file I/O) ──────────────────────────────── |
| 272 | |
| 273 | if grace_period_seconds < 0: |
| 274 | msg = f"--grace-period must be >= 0, got {grace_period_seconds}" |
| 275 | if as_json: |
| 276 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 277 | else: |
| 278 | print(f"❌ {msg}", file=sys.stderr) |
| 279 | raise SystemExit(ExitCode.USER_ERROR) |
| 280 | |
| 281 | if max_intent_age_seconds <= 0: |
| 282 | msg = f"--max-intent-age must be > 0, got {max_intent_age_seconds}" |
| 283 | if as_json: |
| 284 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 285 | else: |
| 286 | print(f"❌ {msg}", file=sys.stderr) |
| 287 | raise SystemExit(ExitCode.USER_ERROR) |
| 288 | |
| 289 | root = require_repo() |
| 290 | |
| 291 | result = run_coord_gc( |
| 292 | root, |
| 293 | dry_run=not execute, |
| 294 | grace_period_seconds=grace_period_seconds, |
| 295 | include_intents=include_intents, |
| 296 | max_intent_age_seconds=max_intent_age_seconds, |
| 297 | ) |
| 298 | |
| 299 | if as_json: |
| 300 | print(json.dumps({ |
| 301 | "dry_run": result.dry_run, |
| 302 | "grace_period_seconds": result.grace_period_seconds, |
| 303 | "include_intents": result.include_intents, |
| 304 | "max_intent_age_seconds": max_intent_age_seconds, |
| 305 | "reservations_removed": result.reservations_removed, |
| 306 | "reservations_removed_bytes": result.reservations_removed_bytes, |
| 307 | "releases_removed": result.releases_removed, |
| 308 | "releases_removed_bytes": result.releases_removed_bytes, |
| 309 | "heartbeats_removed": result.heartbeats_removed, |
| 310 | "heartbeats_removed_bytes": result.heartbeats_removed_bytes, |
| 311 | "intents_removed": result.intents_removed, |
| 312 | "intents_removed_bytes": result.intents_removed_bytes, |
| 313 | "total_removed": result.total_removed, |
| 314 | "total_removed_bytes": result.total_removed_bytes, |
| 315 | "removed_ids": result.removed_ids, |
| 316 | "elapsed_seconds": result.elapsed_seconds, |
| 317 | })) |
| 318 | return |
| 319 | |
| 320 | # ── Text output ─────────────────────────────────────────────────────────── |
| 321 | mode = "DRY RUN — no files deleted" if result.dry_run else "GC complete" |
| 322 | print(f"\nCoordination GC — {mode}") |
| 323 | print("─" * 62) |
| 324 | |
| 325 | if result.total_removed == 0: |
| 326 | print("\n Nothing to collect.") |
| 327 | else: |
| 328 | lines = [ |
| 329 | ("Reservations", result.reservations_removed, result.reservations_removed_bytes), |
| 330 | ("Releases", result.releases_removed, result.releases_removed_bytes), |
| 331 | ("Heartbeats", result.heartbeats_removed, result.heartbeats_removed_bytes), |
| 332 | ] |
| 333 | if include_intents: |
| 334 | lines.append(("Intents", result.intents_removed, result.intents_removed_bytes)) |
| 335 | |
| 336 | for label, count, nbytes in lines: |
| 337 | if count: |
| 338 | verb = "would remove" if result.dry_run else "removed" |
| 339 | print(f" {label:<14} {verb} {count:>4} ({_fmt_bytes(nbytes)})") |
| 340 | |
| 341 | print( |
| 342 | f"\n Total: {result.total_removed} record(s)" |
| 343 | f" ({_fmt_bytes(result.total_removed_bytes)})" |
| 344 | ) |
| 345 | |
| 346 | if verbose and result.removed_ids: |
| 347 | print("\n Removed reservation IDs:") |
| 348 | for rid in result.removed_ids: |
| 349 | print(f" {rid}") |
| 350 | |
| 351 | print( |
| 352 | f"\n grace-period={grace_period_seconds}s" |
| 353 | f" ({result.elapsed_seconds:.3f}s)" |
| 354 | ) |
File History
4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e
Merge branch 'dev' into main
Human
29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago