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