gabriel / muse public
coord_gc.py python
354 lines 13.1 KB
Raw
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