gabriel / muse public
test_cmd_coord_gc.py python
796 lines 33.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for ``muse coord gc``.
2
3 Coverage matrix
4 ---------------
5 Unit
6 ~~~~
7 * run_coord_gc dry_run=True — does not delete files
8 * run_coord_gc dry_run=False — deletes expired records
9 * grace period — recently expired records skipped
10 * include_intents flag — intents purged when opt-in
11 * orphaned release (no reservation) — collected
12 * orphaned heartbeat (no reservation) — collected
13 * released reservation — collected after grace period
14 * heartbeat-extended reservation — not collected until effective expiry passes
15 * _fmt_bytes — all size ranges including TiB
16
17 Integration
18 ~~~~~~~~~~~
19 * Default (dry-run) — shows "DRY RUN", nothing deleted
20 * --execute — actually deletes expired reservations
21 * --grace-period large — recently expired records not deleted
22 * --include-intents — intents counted in output
23 * --verbose — removed IDs printed to output
24 * --format json — valid compact JSON with all required keys
25 * --json shorthand — same result as --format json
26 * Empty repo — exits 0 with "Nothing to collect"
27 * --grace-period -1 — exits USER_ERROR (1)
28 * --max-intent-age 0 — exits USER_ERROR (1)
29 * JSON compact — no newlines inside the object
30 * Dry-run JSON — dry_run=true, nothing deleted
31 * Orphaned heartbeat/release removed even with active reservations present
32
33 E2E
34 ~~~
35 * Full lifecycle: reserve → heartbeat → release → gc → all files gone
36 * Active reservation survives GC; expired neighbour is collected
37 * Concurrent dry-run GC on same repo does not crash
38
39 Stress
40 ~~~~~~
41 * 500 expired reservations GC'd < 3 s
42 * 1000-record mixed repo (active + expired) — only expired collected
43 """
44
45 from __future__ import annotations
46
47 import argparse
48
49 import datetime
50 import json as _json
51 import pathlib
52 import time
53 import uuid as _uuid
54
55 import pytest
56
57 from tests.cli_test_helper import CliRunner
58 from muse.core._types import now_utc_iso
59
60 runner = CliRunner()
61 cli = None
62
63
64 # ── Fixtures ──────────────────────────────────────────────────────────────────
65
66
67 @pytest.fixture()
68 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
69 muse_dir = tmp_path / ".muse"
70 muse_dir.mkdir()
71 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
72 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
73 return tmp_path
74
75
76 # ── Helpers ───────────────────────────────────────────────────────────────────
77
78
79 def _write_expired_reservation(repo: pathlib.Path, run_id: str = "agent-x") -> str:
80 coord_dir = repo / ".muse" / "coordination" / "reservations"
81 coord_dir.mkdir(parents=True, exist_ok=True)
82 rid = str(_uuid.uuid4())
83 now = datetime.datetime.now(datetime.timezone.utc)
84 data = {
85 "reservation_id": rid,
86 "run_id": run_id,
87 "branch": "main",
88 "addresses": ["src/x.py::foo"],
89 "operation": None,
90 "created_at": (now - datetime.timedelta(hours=2)).isoformat(),
91 "expires_at": (now - datetime.timedelta(hours=1)).isoformat(),
92 }
93 (coord_dir / f"{rid}.json").write_text(_json.dumps(data))
94 return rid
95
96
97 def _write_active_reservation(repo: pathlib.Path, run_id: str = "agent-y") -> str:
98 coord_dir = repo / ".muse" / "coordination" / "reservations"
99 coord_dir.mkdir(parents=True, exist_ok=True)
100 rid = str(_uuid.uuid4())
101 now = datetime.datetime.now(datetime.timezone.utc)
102 data = {
103 "reservation_id": rid,
104 "run_id": run_id,
105 "branch": "main",
106 "addresses": ["src/y.py::bar"],
107 "operation": None,
108 "created_at": now.isoformat(),
109 "expires_at": (now + datetime.timedelta(hours=1)).isoformat(),
110 }
111 (coord_dir / f"{rid}.json").write_text(_json.dumps(data))
112 return rid
113
114
115 def _write_expired_intent(repo: pathlib.Path, run_id: str = "agent-z") -> str:
116 intent_dir = repo / ".muse" / "coordination" / "intents"
117 intent_dir.mkdir(parents=True, exist_ok=True)
118 iid = str(_uuid.uuid4())
119 now = datetime.datetime.now(datetime.timezone.utc)
120 data = {
121 "intent_id": iid,
122 "run_id": run_id,
123 "branch": "main",
124 "addresses": ["src/z.py::baz"],
125 "created_at": (now - datetime.timedelta(days=10)).isoformat(),
126 "expires_at": (now - datetime.timedelta(days=9)).isoformat(),
127 }
128 (intent_dir / f"{iid}.json").write_text(_json.dumps(data))
129 return iid
130
131
132 # ── Unit: run_coord_gc ────────────────────────────────────────────────────────
133
134
135 class TestRunCoordGcUnit:
136 def test_dry_run_does_not_delete_files(self, repo: pathlib.Path) -> None:
137 from muse.core.coordination import run_coord_gc
138 rid = _write_expired_reservation(repo)
139 result = run_coord_gc(repo, dry_run=True, grace_period_seconds=0)
140 assert result.dry_run is True
141 # File must still exist after dry run
142 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
143 assert res_file.exists()
144
145 def test_execute_deletes_expired_reservation(self, repo: pathlib.Path) -> None:
146 from muse.core.coordination import run_coord_gc
147 rid = _write_expired_reservation(repo)
148 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
149 assert result.dry_run is False
150 assert result.reservations_removed >= 1
151 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
152 assert not res_file.exists()
153
154 def test_active_reservation_not_deleted(self, repo: pathlib.Path) -> None:
155 from muse.core.coordination import run_coord_gc
156 rid = _write_active_reservation(repo)
157 run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
158 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
159 assert res_file.exists()
160
161 def test_grace_period_protects_recently_expired(self, repo: pathlib.Path) -> None:
162 from muse.core.coordination import run_coord_gc
163 rid = _write_expired_reservation(repo)
164 # Grace period of 7 hours (25200s) > 1 hour elapsed since expiry → skipped
165 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=25200)
166 assert result.reservations_removed == 0
167 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
168 assert res_file.exists()
169
170 def test_include_intents_false_leaves_intents(self, repo: pathlib.Path) -> None:
171 from muse.core.coordination import run_coord_gc
172 iid = _write_expired_intent(repo)
173 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0, include_intents=False)
174 assert result.intents_removed == 0
175 intent_file = repo / ".muse" / "coordination" / "intents" / f"{iid}.json"
176 assert intent_file.exists()
177
178 def test_include_intents_true_removes_old_intents(self, repo: pathlib.Path) -> None:
179 from muse.core.coordination import run_coord_gc
180 _write_expired_intent(repo)
181 result = run_coord_gc(
182 repo,
183 dry_run=False,
184 grace_period_seconds=0,
185 include_intents=True,
186 max_intent_age_seconds=60, # 1 minute — our intent is 10 days old
187 )
188 assert result.intents_removed >= 1
189
190 def test_result_has_duration_ms(self, repo: pathlib.Path) -> None:
191 from muse.core.coordination import run_coord_gc
192 result = run_coord_gc(repo, dry_run=True, grace_period_seconds=0)
193 assert result.duration_ms >= 0.0
194
195 def test_empty_repo_total_removed_is_zero(self, repo: pathlib.Path) -> None:
196 from muse.core.coordination import run_coord_gc
197 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
198 assert result.total_removed == 0
199
200
201 # ── Integration ───────────────────────────────────────────────────────────────
202
203
204 class TestCoordGcIntegration:
205 def test_default_dry_run(self, repo: pathlib.Path) -> None:
206 _write_expired_reservation(repo)
207 result = runner.invoke(cli, ["coord", "gc"])
208 assert result.exit_code == 0
209 assert "DRY RUN" in result.output
210
211 def test_default_dry_run_does_not_delete(self, repo: pathlib.Path) -> None:
212 rid = _write_expired_reservation(repo)
213 runner.invoke(cli, ["coord", "gc"])
214 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
215 assert res_file.exists()
216
217 def test_execute_deletes_expired(self, repo: pathlib.Path) -> None:
218 rid = _write_expired_reservation(repo)
219 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
220 assert result.exit_code == 0
221 res_file = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
222 assert not res_file.exists()
223
224 def test_execute_text_output_shows_gc_complete(self, repo: pathlib.Path) -> None:
225 _write_expired_reservation(repo)
226 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
227 assert result.exit_code == 0
228 assert "GC complete" in result.output
229
230 def test_grace_period_large_nothing_deleted(self, repo: pathlib.Path) -> None:
231 _write_expired_reservation(repo) # expired 1 hour ago
232 # Grace period of 7 hours (25200s) > 1 hour elapsed since expiry → skipped
233 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "25200"])
234 assert result.exit_code == 0
235 assert "Nothing to collect" in result.output
236
237 def test_include_intents_flag_reaches_intents(self, repo: pathlib.Path) -> None:
238 _write_expired_intent(repo)
239 result = runner.invoke(cli, [
240 "coord", "gc", "--execute", "--include-intents",
241 "--max-intent-age", "60", "--grace-period", "0",
242 ])
243 assert result.exit_code == 0
244
245 def test_verbose_prints_removed_ids(self, repo: pathlib.Path) -> None:
246 rid = _write_expired_reservation(repo)
247 result = runner.invoke(cli, [
248 "coord", "gc", "--execute", "--grace-period", "0", "--verbose",
249 ])
250 assert result.exit_code == 0
251 assert rid in result.output
252
253 def test_format_json_valid_structure(self, repo: pathlib.Path) -> None:
254 _write_expired_reservation(repo)
255 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0", "--json"])
256 assert result.exit_code == 0
257 data = _json.loads(result.output.strip())
258 required_keys = {
259 "dry_run", "grace_period_seconds", "include_intents", "max_intent_age_seconds",
260 "reservations_removed", "reservations_removed_bytes", "releases_removed",
261 "releases_removed_bytes", "heartbeats_removed", "heartbeats_removed_bytes",
262 "intents_removed", "intents_removed_bytes", "total_removed", "total_removed_bytes",
263 "removed_ids", "duration_ms",
264 }
265 assert required_keys <= set(data)
266
267 def test_json_shorthand(self, repo: pathlib.Path) -> None:
268 result = runner.invoke(cli, ["coord", "gc", "--json"])
269 assert result.exit_code == 0
270 data = _json.loads(result.output.strip())
271 assert "dry_run" in data
272 assert data["dry_run"] is True
273
274 def test_empty_repo_exits_0_nothing_to_collect(self, repo: pathlib.Path) -> None:
275 result = runner.invoke(cli, ["coord", "gc"])
276 assert result.exit_code == 0
277 assert "Nothing to collect" in result.output
278
279 def test_grace_period_negative_exits_user_error(self, repo: pathlib.Path) -> None:
280 from muse.core.errors import ExitCode
281 result = runner.invoke(cli, ["coord", "gc", "--grace-period", "-1"])
282 assert result.exit_code == ExitCode.USER_ERROR
283
284 def test_max_intent_age_zero_exits_user_error(self, repo: pathlib.Path) -> None:
285 from muse.core.errors import ExitCode
286 result = runner.invoke(cli, ["coord", "gc", "--max-intent-age", "0"])
287 assert result.exit_code == ExitCode.USER_ERROR
288
289 def test_json_dry_run_field_true_by_default(self, repo: pathlib.Path) -> None:
290 result = runner.invoke(cli, ["coord", "gc", "--json"])
291 data = _json.loads(result.output.strip())
292 assert data["dry_run"] is True
293
294 def test_json_execute_dry_run_field_false(self, repo: pathlib.Path) -> None:
295 result = runner.invoke(cli, ["coord", "gc", "--execute", "--json"])
296 data = _json.loads(result.output.strip())
297 assert data["dry_run"] is False
298
299 def test_duration_ms_is_nonnegative_float(self, repo: pathlib.Path) -> None:
300 result = runner.invoke(cli, ["coord", "gc", "--json"])
301 data = _json.loads(result.output.strip())
302 assert isinstance(data["duration_ms"], float)
303 assert data["duration_ms"] >= 0.0
304
305
306 # ── Security ──────────────────────────────────────────────────────────────────
307
308
309 class TestCoordGcSecurity:
310 def test_gc_does_not_traverse_outside_coordination_dir(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
311 """Verify GC only touches .muse/coordination/ subdirectories."""
312 sentinel = tmp_path / "outside_sentinel.json"
313 sentinel.write_text('{"should": "not be deleted"}')
314
315 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
316 assert result.exit_code == 0
317 assert sentinel.exists(), "GC must not delete files outside .muse/coordination/"
318
319 def test_symlink_outside_repo_not_followed(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
320 """A symlink inside coordination/ pointing outside the repo is skipped."""
321 import os
322 outside_file = tmp_path / "secret.json"
323 outside_file.write_text('{"secret": "data"}')
324
325 reservations_dir = repo / ".muse" / "coordination" / "reservations"
326 reservations_dir.mkdir(parents=True, exist_ok=True)
327 link = reservations_dir / "evil_link.json"
328 try:
329 os.symlink(outside_file, link)
330 except OSError:
331 pytest.skip("symlink creation not supported")
332
333 # GC should not crash and should not delete the outside file
334 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
335 assert result.exit_code == 0
336 assert outside_file.exists()
337
338
339 # ── Stress ────────────────────────────────────────────────────────────────────
340
341
342 class TestCoordGcStress:
343 def test_500_expired_reservations_under_3s(self, repo: pathlib.Path) -> None:
344 for _ in range(500):
345 _write_expired_reservation(repo)
346
347 t0 = time.monotonic()
348 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
349 elapsed = time.monotonic() - t0
350
351 assert result.exit_code == 0
352 assert elapsed < 3.0
353 data_lines = result.output
354 assert "GC complete" in data_lines or "removed" in data_lines.lower()
355
356 # Verify all files are gone
357 reservations_dir = repo / ".muse" / "coordination" / "reservations"
358 remaining = list(reservations_dir.glob("*.json"))
359 assert len(remaining) == 0
360
361 def test_1000_mixed_repo_only_expired_collected(self, repo: pathlib.Path) -> None:
362 """500 active + 500 expired: only the expired set is removed."""
363 for _ in range(500):
364 _write_expired_reservation(repo)
365 active_ids = {_write_active_reservation(repo) for _ in range(500)}
366
367 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0"])
368 assert result.exit_code == 0
369
370 reservations_dir = repo / ".muse" / "coordination" / "reservations"
371 surviving = {p.stem for p in reservations_dir.glob("*.json")}
372 assert active_ids == surviving, "Active reservations must not be collected"
373
374
375 # ---------------------------------------------------------------------------
376 # Unit — _fmt_bytes
377 # ---------------------------------------------------------------------------
378
379
380 class TestFmtBytes:
381 def test_zero(self) -> None:
382 from muse.cli.commands.coord_gc import _fmt_bytes
383 assert _fmt_bytes(0) == "0 B"
384
385 def test_under_1024(self) -> None:
386 from muse.cli.commands.coord_gc import _fmt_bytes
387 assert _fmt_bytes(1023) == "1023 B"
388
389 def test_exactly_1_kib(self) -> None:
390 from muse.cli.commands.coord_gc import _fmt_bytes
391 assert _fmt_bytes(1024) == "1.0 KiB"
392
393 def test_exactly_1_mib(self) -> None:
394 from muse.cli.commands.coord_gc import _fmt_bytes
395 assert _fmt_bytes(1024 ** 2) == "1.0 MiB"
396
397 def test_exactly_1_gib(self) -> None:
398 from muse.cli.commands.coord_gc import _fmt_bytes
399 assert _fmt_bytes(1024 ** 3) == "1.0 GiB"
400
401 def test_exactly_1_tib(self) -> None:
402 from muse.cli.commands.coord_gc import _fmt_bytes
403 assert _fmt_bytes(1024 ** 4) == "1.0 TiB"
404
405 def test_2_tib(self) -> None:
406 from muse.cli.commands.coord_gc import _fmt_bytes
407 assert _fmt_bytes(2 * 1024 ** 4) == "2.0 TiB"
408
409 def test_gib_does_not_overflow_to_wrong_unit(self) -> None:
410 from muse.cli.commands.coord_gc import _fmt_bytes
411 # 512 GiB — must stay in GiB, not TiB
412 assert _fmt_bytes(512 * 1024 ** 3) == "512.0 GiB"
413
414 def test_1023_gib_stays_gib(self) -> None:
415 from muse.cli.commands.coord_gc import _fmt_bytes
416 result = _fmt_bytes(1023 * 1024 ** 3)
417 assert "GiB" in result
418
419
420 # ---------------------------------------------------------------------------
421 # Unit — orphan / lifecycle
422 # ---------------------------------------------------------------------------
423
424
425 class TestRunCoordGcOrphanAndLifecycle:
426 def test_orphaned_release_collected(self, repo: pathlib.Path) -> None:
427 """A release tombstone with no matching reservation is an orphan — collect it."""
428 from muse.core.coordination import run_coord_gc
429 releases_dir = repo / ".muse" / "coordination" / "releases"
430 releases_dir.mkdir(parents=True, exist_ok=True)
431 orphan_id = str(_uuid.uuid4())
432 orphan_path = releases_dir / f"{orphan_id}.json"
433 orphan_path.write_text(_json.dumps({
434 "reservation_id": orphan_id,
435 "run_id": "ghost",
436 "released_at": now_utc_iso(),
437 "reason": "completed",
438 }))
439 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
440 assert result.releases_removed >= 1
441 assert not orphan_path.exists()
442
443 def test_orphaned_heartbeat_collected(self, repo: pathlib.Path) -> None:
444 """A heartbeat file with no matching reservation is an orphan — collect it."""
445 from muse.core.coordination import run_coord_gc
446 hb_dir = repo / ".muse" / "coordination" / "heartbeats"
447 hb_dir.mkdir(parents=True, exist_ok=True)
448 orphan_id = str(_uuid.uuid4())
449 orphan_path = hb_dir / f"{orphan_id}.json"
450 now = datetime.datetime.now(datetime.timezone.utc)
451 orphan_path.write_text(_json.dumps({
452 "reservation_id": orphan_id,
453 "run_id": "ghost",
454 "last_beat_at": now.isoformat(),
455 "extended_expires_at": (now + datetime.timedelta(hours=1)).isoformat(),
456 }))
457 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
458 assert result.heartbeats_removed >= 1
459 assert not orphan_path.exists()
460
461 def test_released_reservation_collected_after_grace(self, repo: pathlib.Path) -> None:
462 """Reserve → release → gc(grace=0): reservation + tombstone both gone."""
463 from muse.core.coordination import (
464 run_coord_gc, create_reservation, create_release
465 )
466 res = create_reservation(
467 repo, run_id="agent-r", branch="main",
468 addresses=["a.py::f"], ttl_seconds=3600,
469 )
470 rid = res.reservation_id
471 create_release(repo, rid, run_id="agent-r")
472
473 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
474 assert result.reservations_removed >= 1
475 assert result.releases_removed >= 1
476 res_path = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
477 rel_path = repo / ".muse" / "coordination" / "releases" / f"{rid}.json"
478 assert not res_path.exists()
479 assert not rel_path.exists()
480
481 def test_heartbeat_extended_reservation_not_expired(self, repo: pathlib.Path) -> None:
482 """A reservation past its original TTL but heartbeated is still active."""
483 from muse.core.coordination import (
484 run_coord_gc, create_reservation, create_heartbeat
485 )
486 # Reservation expired 30 minutes ago
487 coord_dir = repo / ".muse" / "coordination" / "reservations"
488 coord_dir.mkdir(parents=True, exist_ok=True)
489 rid = str(_uuid.uuid4())
490 now = datetime.datetime.now(datetime.timezone.utc)
491 data = {
492 "reservation_id": rid, "run_id": "agent-hb", "branch": "main",
493 "addresses": ["a.py::f"], "operation": None,
494 "created_at": (now - datetime.timedelta(hours=2)).isoformat(),
495 "expires_at": (now - datetime.timedelta(minutes=30)).isoformat(),
496 }
497 (coord_dir / f"{rid}.json").write_text(_json.dumps(data))
498 # Heartbeat extends it 2 hours into the future
499 hb_dir = repo / ".muse" / "coordination" / "heartbeats"
500 hb_dir.mkdir(parents=True, exist_ok=True)
501 (hb_dir / f"{rid}.json").write_text(_json.dumps({
502 "reservation_id": rid, "run_id": "agent-hb",
503 "last_beat_at": now.isoformat(),
504 "extended_expires_at": (now + datetime.timedelta(hours=2)).isoformat(),
505 }))
506
507 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
508 assert result.reservations_removed == 0
509 assert (coord_dir / f"{rid}.json").exists(), "Heartbeated reservation must survive"
510
511 def test_dry_run_removed_ids_populated(self, repo: pathlib.Path) -> None:
512 """dry_run=True must populate removed_ids even though nothing is deleted."""
513 from muse.core.coordination import run_coord_gc
514 rid = _write_expired_reservation(repo)
515 result = run_coord_gc(repo, dry_run=True, grace_period_seconds=0)
516 assert rid in result.removed_ids
517 res_path = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
518 assert res_path.exists(), "dry_run must not delete files"
519
520 def test_total_removed_is_sum_of_parts(self, repo: pathlib.Path) -> None:
521 """total_removed == sum of all category counters."""
522 from muse.core.coordination import run_coord_gc
523 _write_expired_reservation(repo)
524 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
525 expected = (
526 result.reservations_removed
527 + result.releases_removed
528 + result.heartbeats_removed
529 + result.intents_removed
530 )
531 assert result.total_removed == expected
532
533 def test_total_bytes_is_sum_of_parts(self, repo: pathlib.Path) -> None:
534 """total_removed_bytes == sum of all byte counters."""
535 from muse.core.coordination import run_coord_gc
536 _write_expired_reservation(repo)
537 result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
538 expected = (
539 result.reservations_removed_bytes
540 + result.releases_removed_bytes
541 + result.heartbeats_removed_bytes
542 + result.intents_removed_bytes
543 )
544 assert result.total_removed_bytes == expected
545
546
547 # ---------------------------------------------------------------------------
548 # Integration — JSON compact format and new exit codes
549 # ---------------------------------------------------------------------------
550
551
552 class TestCoordGcJsonAndExitCodes:
553 def test_json_output_is_compact(self, repo: pathlib.Path) -> None:
554 """JSON must be single-line (no indent=2 pretty-printing)."""
555 result = runner.invoke(cli, ["coord", "gc", "--json"])
556 assert result.exit_code == 0
557 assert "\n" not in result.output.strip()
558
559 def test_json_execute_compact(self, repo: pathlib.Path) -> None:
560 _write_expired_reservation(repo)
561 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0", "--json"])
562 assert result.exit_code == 0
563 assert "\n" not in result.output.strip()
564
565 def test_grace_period_negative_json_has_status(self, repo: pathlib.Path) -> None:
566 result = runner.invoke(cli, ["coord", "gc", "--grace-period", "-1", "--json"])
567 data = _json.loads(result.output)
568 assert data["status"] == "bad_args"
569
570 def test_max_intent_age_zero_json_has_status(self, repo: pathlib.Path) -> None:
571 result = runner.invoke(cli, ["coord", "gc", "--max-intent-age", "0", "--json"])
572 data = _json.loads(result.output)
573 assert data["status"] == "bad_args"
574
575 def test_grace_period_negative_exits_user_error(self, repo: pathlib.Path) -> None:
576 from muse.core.errors import ExitCode
577 result = runner.invoke(cli, ["coord", "gc", "--grace-period", "-1"])
578 assert result.exit_code == ExitCode.USER_ERROR
579
580 def test_max_intent_age_zero_exits_user_error(self, repo: pathlib.Path) -> None:
581 from muse.core.errors import ExitCode
582 result = runner.invoke(cli, ["coord", "gc", "--max-intent-age", "0"])
583 assert result.exit_code == ExitCode.USER_ERROR
584
585 def test_json_removed_ids_list_populated(self, repo: pathlib.Path) -> None:
586 rid = _write_expired_reservation(repo)
587 result = runner.invoke(cli, ["coord", "gc", "--execute", "--grace-period", "0", "--json"])
588 data = _json.loads(result.output)
589 assert rid in data["removed_ids"]
590
591 def test_json_dry_run_removed_ids_populated(self, repo: pathlib.Path) -> None:
592 """removed_ids is populated in dry-run JSON too."""
593 rid = _write_expired_reservation(repo)
594 result = runner.invoke(cli, ["coord", "gc", "--json"])
595 data = _json.loads(result.output)
596 assert rid in data["removed_ids"]
597 assert data["dry_run"] is True
598
599 def test_orphaned_release_in_json_output(self, repo: pathlib.Path) -> None:
600 """Orphaned release collected and reflected in JSON releases_removed."""
601 releases_dir = repo / ".muse" / "coordination" / "releases"
602 releases_dir.mkdir(parents=True, exist_ok=True)
603 orphan_id = str(_uuid.uuid4())
604 (releases_dir / f"{orphan_id}.json").write_text(_json.dumps({
605 "reservation_id": orphan_id,
606 "run_id": "ghost",
607 "released_at": now_utc_iso(),
608 "reason": "completed",
609 }))
610 result = runner.invoke(cli, [
611 "coord", "gc", "--execute", "--grace-period", "0", "--json"
612 ])
613 data = _json.loads(result.output)
614 assert data["releases_removed"] >= 1
615
616 def test_error_message_uses_emoji_prefix(self, repo: pathlib.Path) -> None:
617 """Validation errors must start with ❌, not bare 'error:'."""
618 result = runner.invoke(cli, ["coord", "gc", "--grace-period", "-1"])
619 combined = result.output + (result.stderr or "")
620 assert "❌" in combined
621
622
623 # ---------------------------------------------------------------------------
624 # E2E — full lifecycle
625 # ---------------------------------------------------------------------------
626
627
628 class TestCoordGcE2E:
629 def test_full_lifecycle_reserve_release_gc(self, repo: pathlib.Path) -> None:
630 """Reserve → release → GC: reservation + tombstone both removed."""
631 from muse.core.coordination import (
632 create_reservation, create_release, run_coord_gc,
633 load_all_reservations, load_released_ids,
634 )
635 res = create_reservation(
636 repo, run_id="e2e-agent", branch="main",
637 addresses=["src/e2e.py::func"], ttl_seconds=3600,
638 )
639 rid = res.reservation_id
640 create_release(repo, rid, run_id="e2e-agent")
641
642 gc_result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
643 assert rid in gc_result.removed_ids
644
645 res_path = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
646 rel_path = repo / ".muse" / "coordination" / "releases" / f"{rid}.json"
647 assert not res_path.exists()
648 assert not rel_path.exists()
649
650 def test_active_survives_while_expired_neighbour_collected(self, repo: pathlib.Path) -> None:
651 """One active + one expired: only expired is collected."""
652 from muse.core.coordination import run_coord_gc
653 active_id = _write_active_reservation(repo)
654 expired_id = _write_expired_reservation(repo)
655
656 run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
657
658 res_dir = repo / ".muse" / "coordination" / "reservations"
659 surviving = {p.stem for p in res_dir.glob("*.json")}
660 assert active_id in surviving
661 assert expired_id not in surviving
662
663 def test_gc_with_heartbeat_extended_reservation(self, repo: pathlib.Path) -> None:
664 """Expired-by-TTL but heartbeat-extended: survives GC."""
665 from muse.core.coordination import (
666 create_reservation, create_heartbeat, run_coord_gc
667 )
668 res = create_reservation(
669 repo, run_id="hb-agent", branch="main",
670 addresses=["src/hb.py::func"], ttl_seconds=1,
671 )
672 rid = res.reservation_id
673 # Heartbeat extends 2 hours into the future
674 create_heartbeat(repo, rid, run_id="hb-agent", extension_seconds=7200)
675
676 gc_result = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
677 assert rid not in gc_result.removed_ids
678 res_path = repo / ".muse" / "coordination" / "reservations" / f"{rid}.json"
679 assert res_path.exists()
680
681 def test_repeated_gc_is_idempotent(self, repo: pathlib.Path) -> None:
682 """Running GC twice on an already-clean repo returns zero totals."""
683 from muse.core.coordination import run_coord_gc
684 _write_expired_reservation(repo)
685 run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
686 result2 = run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
687 assert result2.total_removed == 0
688
689 def test_gc_via_cli_full_lifecycle(self, repo: pathlib.Path) -> None:
690 """End-to-end via CLI: reserve → release → gc --execute → JSON confirms removal."""
691 from muse.core.coordination import create_reservation, create_release
692 res = create_reservation(
693 repo, run_id="cli-e2e", branch="main",
694 addresses=["src/cli.py::func"], ttl_seconds=3600,
695 )
696 rid = res.reservation_id
697 create_release(repo, rid, run_id="cli-e2e")
698
699 result = runner.invoke(cli, [
700 "coord", "gc", "--execute", "--grace-period", "0", "--json",
701 ])
702 assert result.exit_code == 0
703 data = _json.loads(result.output)
704 assert rid in data["removed_ids"]
705 assert data["reservations_removed"] >= 1
706 assert data["releases_removed"] >= 1
707
708
709 # ---------------------------------------------------------------------------
710 # Concurrent
711 # ---------------------------------------------------------------------------
712
713
714 class TestCoordGcConcurrent:
715 def test_concurrent_dry_run_does_not_crash(self, repo: pathlib.Path) -> None:
716 """20 concurrent dry-run GC passes on the same repo must all succeed."""
717 import threading
718 for _ in range(50):
719 _write_expired_reservation(repo)
720
721 errors: list[Exception] = []
722 lock = threading.Lock()
723
724 def _gc() -> None:
725 try:
726 result = runner.invoke(cli, ["coord", "gc", "--json"])
727 assert result.exit_code == 0
728 except Exception as exc: # noqa: BLE001
729 with lock:
730 errors.append(exc)
731
732 threads = [threading.Thread(target=_gc) for _ in range(20)]
733 for t in threads:
734 t.start()
735 for t in threads:
736 t.join()
737
738 assert not errors, f"Concurrent dry-run errors: {errors}"
739
740 def test_concurrent_execute_leaves_no_files(self, repo: pathlib.Path) -> None:
741 """Two concurrent execute passes must not leave any collectable files on disk.
742
743 Both passes may succeed or one may race ahead — either is fine.
744 The critical invariant is that all expired files are gone afterward.
745 """
746 import threading
747 from muse.core.coordination import run_coord_gc
748 for _ in range(100):
749 _write_expired_reservation(repo)
750
751 def _gc() -> None:
752 run_coord_gc(repo, dry_run=False, grace_period_seconds=0)
753
754 t1 = threading.Thread(target=_gc)
755 t2 = threading.Thread(target=_gc)
756 t1.start()
757 t2.start()
758 t1.join()
759 t2.join()
760
761 res_dir = repo / ".muse" / "coordination" / "reservations"
762 if res_dir.exists():
763 remaining = list(res_dir.glob("*.json"))
764 assert remaining == [], f"Files left after concurrent GC: {remaining}"
765
766
767 # ---------------------------------------------------------------------------
768 # TestRegisterFlags — --json / -j normalized at argparse level
769 # ---------------------------------------------------------------------------
770
771
772 class TestRegisterFlags:
773 """register() must expose --json with -j shorthand and dest=json_out."""
774
775 def _make_parser(self):
776 import argparse as ap
777 from muse.cli.commands.coord_gc import register
778 root = ap.ArgumentParser()
779 subs = root.add_subparsers()
780 register(subs)
781 return root
782
783 def test_json_out_default_false(self) -> None:
784 p = self._make_parser()
785 ns = p.parse_args(['gc'])
786 assert ns.json_out is False
787
788 def test_json_out_true_with_json_flag(self) -> None:
789 p = self._make_parser()
790 ns = p.parse_args(['gc', '--json'])
791 assert ns.json_out is True
792
793 def test_json_out_true_with_j_flag(self) -> None:
794 p = self._make_parser()
795 ns = p.parse_args(['gc', '-j'])
796 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago