gabriel / muse public
test_cmd_release_coord.py python
766 lines 29.8 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 release``.
2
3 Coverage matrix
4 ---------------
5 Unit
6 ~~~~
7 * create_release roundtrip — write, load, fields intact
8 * Release.to_dict — required keys present
9 * Valid reasons accepted — completed, cancelled, superseded
10 * Invalid reason rejected — ValueError raised
11 * Double release raises FileExistsError
12 * Path traversal in reservation_id rejected before file I/O
13
14 Integration — CLI
15 ~~~~~~~~~~~~~~~~~
16 * Single release — text output, exit 0
17 * --reason cancelled — accepted
18 * --reason superseded — accepted
19 * --all-for-run — releases all active reservations for run-id
20 * --all-for-run with no active reservations — 0 released, exit 0
21 * Double release exits 0 (already released — idempotent)
22 * Not-found UUID exits ExitCode.NOT_FOUND (4)
23 * Invalid UUID exits ExitCode.USER_ERROR (1)
24 * --format json — valid JSON, required keys, compact (no indent)
25 * --json shorthand — valid JSON
26
27 Input validation
28 ~~~~~~~~~~~~~~~~
29 * --run-id at exactly 256 chars accepted
30 * --run-id over 256 chars rejected with USER_ERROR (1)
31 * --run-id validation fires before any file I/O (no reservation file left)
32 * Mutual exclusion errors use USER_ERROR (1)
33
34 Security
35 ~~~~~~~~
36 * UUID path traversal rejected before file I/O
37 * ANSI escape sequences in run_id are safe (sanitized in output)
38 * null byte in reservation_id rejected
39 * UUID validation before require_repo — no side effects on bad input
40
41 Concurrent
42 ~~~~~~~~~~
43 * Two threads racing to release the same reservation — both exit 0
44
45 Stress
46 ~~~~~~
47 * 200 reservations released via --all-for-run < 3 s
48 * 50 concurrent --all-for-run reads do not corrupt each other
49 """
50
51 from __future__ import annotations
52
53 import datetime
54 import json
55 import pathlib
56 import time
57 import uuid
58
59 import threading
60
61 import pytest
62
63 from tests.cli_test_helper import CliRunner
64 from muse.core.coordination import (
65 Release,
66 Reservation,
67 create_release,
68 create_reservation,
69 load_all_reservations,
70 load_released_ids,
71 )
72 from muse.cli.commands.release_coord import _MAX_RUN_ID_LEN
73 from muse.core.errors import ExitCode
74
75 cli = None
76 runner = CliRunner()
77
78
79 # ---------------------------------------------------------------------------
80 # Helpers
81 # ---------------------------------------------------------------------------
82
83
84 def _now_utc() -> datetime.datetime:
85 return datetime.datetime.now(datetime.timezone.utc)
86
87
88 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
89 muse_dir = tmp_path / ".muse"
90 muse_dir.mkdir()
91 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
92 return tmp_path
93
94
95 @pytest.fixture()
96 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
97 monkeypatch.chdir(tmp_path)
98 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
99 r = runner.invoke(cli, ["init", "--domain", "code"])
100 assert r.exit_code == 0, r.output
101 return tmp_path
102
103
104 @pytest.fixture()
105 def reservation(repo: pathlib.Path) -> Reservation:
106 return create_reservation(
107 repo,
108 run_id="agent-test",
109 branch="feat/test",
110 addresses=["src/billing.py::compute_total"],
111 ttl_seconds=3600,
112 )
113
114
115 # ---------------------------------------------------------------------------
116 # Unit — core helpers
117 # ---------------------------------------------------------------------------
118
119
120 class TestCreateReleaseRoundtrip:
121 def test_roundtrip_fields(self, tmp_path: pathlib.Path) -> None:
122 root = _make_repo(tmp_path)
123 res = create_reservation(root, run_id="agent-1", branch="main",
124 addresses=["a.py::f"], ttl_seconds=3600)
125 rid = res.reservation_id
126 rel = create_release(root, rid, run_id="agent-1", reason="completed")
127 assert rel.reservation_id == rid
128 assert rel.run_id == "agent-1"
129 assert rel.reason == "completed"
130 assert isinstance(rel.released_at, datetime.datetime)
131
132 def test_release_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
133 root = _make_repo(tmp_path)
134 res = create_reservation(root, run_id="agent-1", branch="main",
135 addresses=["a.py::f"], ttl_seconds=3600)
136 rid = res.reservation_id
137 create_release(root, rid, run_id="agent-1", reason="completed")
138 released = load_released_ids(root)
139 assert rid in released
140
141 def test_to_dict_keys(self, tmp_path: pathlib.Path) -> None:
142 root = _make_repo(tmp_path)
143 res = create_reservation(root, run_id="agent-1", branch="main",
144 addresses=["a.py::f"], ttl_seconds=3600)
145 rel = create_release(root, res.reservation_id, run_id="agent-1")
146 d = rel.to_dict()
147 assert "reservation_id" in d
148 assert "run_id" in d
149 assert "released_at" in d
150 assert "reason" in d
151 assert "schema_version" in d
152
153 def test_valid_reason_completed(self, tmp_path: pathlib.Path) -> None:
154 root = _make_repo(tmp_path)
155 res = create_reservation(root, run_id="a", branch="main",
156 addresses=["x.py::g"], ttl_seconds=3600)
157 rel = create_release(root, res.reservation_id, run_id="a", reason="completed")
158 assert rel.reason == "completed"
159
160 def test_valid_reason_cancelled(self, tmp_path: pathlib.Path) -> None:
161 root = _make_repo(tmp_path)
162 res = create_reservation(root, run_id="a", branch="main",
163 addresses=["x.py::g"], ttl_seconds=3600)
164 rel = create_release(root, res.reservation_id, run_id="a", reason="cancelled")
165 assert rel.reason == "cancelled"
166
167 def test_valid_reason_superseded(self, tmp_path: pathlib.Path) -> None:
168 root = _make_repo(tmp_path)
169 res = create_reservation(root, run_id="a", branch="main",
170 addresses=["x.py::g"], ttl_seconds=3600)
171 rel = create_release(root, res.reservation_id, run_id="a", reason="superseded")
172 assert rel.reason == "superseded"
173
174 def test_invalid_reason_raises(self, tmp_path: pathlib.Path) -> None:
175 root = _make_repo(tmp_path)
176 res = create_reservation(root, run_id="a", branch="main",
177 addresses=["x.py::g"], ttl_seconds=3600)
178 with pytest.raises(ValueError, match="reason must be one of"):
179 create_release(root, res.reservation_id, run_id="a", reason="bogus")
180
181 def test_double_release_raises_file_exists(self, tmp_path: pathlib.Path) -> None:
182 root = _make_repo(tmp_path)
183 res = create_reservation(root, run_id="a", branch="main",
184 addresses=["x.py::g"], ttl_seconds=3600)
185 create_release(root, res.reservation_id, run_id="a")
186 with pytest.raises(FileExistsError):
187 create_release(root, res.reservation_id, run_id="a")
188
189 def test_path_traversal_reservation_id_rejected(self, tmp_path: pathlib.Path) -> None:
190 root = _make_repo(tmp_path)
191 with pytest.raises(ValueError, match="must be a valid UUID"):
192 create_release(root, "../../etc/passwd", run_id="a")
193
194 def test_path_traversal_with_slashes_rejected(self, tmp_path: pathlib.Path) -> None:
195 root = _make_repo(tmp_path)
196 with pytest.raises(ValueError):
197 create_release(root, "../evil/path", run_id="a")
198
199 def test_non_uuid_string_rejected(self, tmp_path: pathlib.Path) -> None:
200 root = _make_repo(tmp_path)
201 with pytest.raises(ValueError):
202 create_release(root, "not-a-uuid", run_id="a")
203
204
205 # ---------------------------------------------------------------------------
206 # Integration — CLI
207 # ---------------------------------------------------------------------------
208
209
210 class TestReleaseSingleCli:
211 def test_basic_release_exits_zero(self, repo: pathlib.Path, reservation: Reservation) -> None:
212 rid = reservation.reservation_id
213 result = runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
214 assert result.exit_code == 0, result.output
215
216 def test_basic_release_text_contains_uuid(self, repo: pathlib.Path, reservation: Reservation) -> None:
217 rid = reservation.reservation_id
218 result = runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
219 assert rid[:8] in result.output or rid in result.output
220
221 def test_basic_release_text_contains_released(self, repo: pathlib.Path, reservation: Reservation) -> None:
222 rid = reservation.reservation_id
223 result = runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
224 assert "released" in result.output.lower()
225
226 def test_reason_cancelled(self, repo: pathlib.Path, reservation: Reservation) -> None:
227 rid = reservation.reservation_id
228 result = runner.invoke(
229 cli, ["coord", "release", rid, "--run-id", "agent-1", "--reason", "cancelled"]
230 )
231 assert result.exit_code == 0, result.output
232 assert "cancelled" in result.output
233
234 def test_reason_superseded(self, repo: pathlib.Path, reservation: Reservation) -> None:
235 rid = reservation.reservation_id
236 result = runner.invoke(
237 cli, ["coord", "release", rid, "--run-id", "agent-1", "--reason", "superseded"]
238 )
239 assert result.exit_code == 0, result.output
240 assert "superseded" in result.output
241
242 def test_double_release_exits_zero_idempotent(self, repo: pathlib.Path, reservation: Reservation) -> None:
243 rid = reservation.reservation_id
244 r1 = runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
245 assert r1.exit_code == 0, r1.output
246 r2 = runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
247 # Already released — idempotent path returns 0
248 assert r2.exit_code == 0, r2.output
249 assert "already released" in r2.output.lower()
250
251 def test_not_found_uuid_exits_nonzero(self, repo: pathlib.Path) -> None:
252 nonexistent = str(uuid.uuid4())
253 result = runner.invoke(
254 cli, ["coord", "release", nonexistent, "--run-id", "agent-1"]
255 )
256 assert result.exit_code != 0
257 combined = result.output + (result.stderr or "")
258 assert "not found" in combined.lower()
259
260 def test_invalid_uuid_exits_nonzero(self, repo: pathlib.Path) -> None:
261 result = runner.invoke(
262 cli, ["coord", "release", "not-a-uuid", "--run-id", "agent-1"]
263 )
264 assert result.exit_code != 0
265
266 def test_no_reservation_id_and_no_all_for_run_exits_nonzero(
267 self, repo: pathlib.Path
268 ) -> None:
269 result = runner.invoke(cli, ["coord", "release", "--run-id", "agent-1"])
270 assert result.exit_code != 0
271
272 def test_both_reservation_id_and_all_for_run_exits_nonzero(
273 self, repo: pathlib.Path, reservation: Reservation
274 ) -> None:
275 rid = reservation.reservation_id
276 result = runner.invoke(
277 cli,
278 ["coord", "release", rid, "--run-id", "agent-1", "--all-for-run", "agent-1"],
279 )
280 assert result.exit_code != 0
281
282
283 class TestReleaseFormatJson:
284 def test_format_json_flag(self, repo: pathlib.Path, reservation: Reservation) -> None:
285 rid = reservation.reservation_id
286 result = runner.invoke(
287 cli,
288 ["coord", "release", rid, "--run-id", "agent-1", "--format", "json"],
289 )
290 assert result.exit_code == 0, result.output
291 data = json.loads(result.output)
292 assert data["reservation_id"] == rid
293 assert data["run_id"] == "agent-1"
294 assert "reason" in data
295 assert "released_at" in data
296
297 def test_json_shorthand(self, repo: pathlib.Path, reservation: Reservation) -> None:
298 rid = reservation.reservation_id
299 result = runner.invoke(
300 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
301 )
302 assert result.exit_code == 0, result.output
303 data = json.loads(result.output)
304 assert data["reservation_id"] == rid
305
306 def test_json_status_released(self, repo: pathlib.Path, reservation: Reservation) -> None:
307 rid = reservation.reservation_id
308 result = runner.invoke(
309 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
310 )
311 data = json.loads(result.output)
312 assert data["status"] == "released"
313
314 def test_json_elapsed_seconds_present(self, repo: pathlib.Path, reservation: Reservation) -> None:
315 rid = reservation.reservation_id
316 result = runner.invoke(
317 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
318 )
319 data = json.loads(result.output)
320 assert "elapsed_seconds" in data
321 assert isinstance(data["elapsed_seconds"], float)
322
323 def test_json_not_found_has_status(self, repo: pathlib.Path) -> None:
324 nonexistent = str(uuid.uuid4())
325 result = runner.invoke(
326 cli, ["coord", "release", nonexistent, "--run-id", "agent-1", "--json"]
327 )
328 data = json.loads(result.output)
329 assert data["status"] == "not_found"
330
331 def test_json_already_released_has_status(self, repo: pathlib.Path, reservation: Reservation) -> None:
332 rid = reservation.reservation_id
333 runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
334 result = runner.invoke(
335 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
336 )
337 data = json.loads(result.output)
338 assert data["status"] == "already_released"
339
340
341 class TestReleaseAllForRun:
342 def test_all_for_run_releases_all(self, repo: pathlib.Path) -> None:
343 for i in range(5):
344 create_reservation(
345 repo, run_id="batch-agent", branch="main",
346 addresses=[f"src/m{i}.py::f"], ttl_seconds=3600,
347 )
348 result = runner.invoke(
349 cli, ["coord", "release", "--all-for-run", "batch-agent", "--run-id", "batch-agent"]
350 )
351 assert result.exit_code == 0, result.output
352 released = load_released_ids(repo)
353 all_res = load_all_reservations(repo)
354 batch_ids = {r.reservation_id for r in all_res if r.run_id == "batch-agent"}
355 assert batch_ids == batch_ids & released
356
357 def test_all_for_run_no_active_exits_zero(self, repo: pathlib.Path) -> None:
358 result = runner.invoke(
359 cli,
360 ["coord", "release", "--all-for-run", "nonexistent-run", "--run-id", "agent-1"],
361 )
362 assert result.exit_code == 0, result.output
363
364 def test_all_for_run_no_active_text_shows_zero(self, repo: pathlib.Path) -> None:
365 result = runner.invoke(
366 cli,
367 ["coord", "release", "--all-for-run", "nonexistent-run", "--run-id", "agent-1"],
368 )
369 assert "0" in result.output
370
371 def test_all_for_run_json_schema(self, repo: pathlib.Path) -> None:
372 for i in range(3):
373 create_reservation(
374 repo, run_id="run-j", branch="main",
375 addresses=[f"src/x{i}.py::f"], ttl_seconds=3600,
376 )
377 result = runner.invoke(
378 cli,
379 ["coord", "release", "--all-for-run", "run-j", "--run-id", "run-j", "--json"],
380 )
381 assert result.exit_code == 0, result.output
382 data = json.loads(result.output)
383 assert "released" in data
384 assert "skipped_already_released" in data
385 assert "elapsed_seconds" in data
386 assert data["status"] == "ok"
387 assert len(data["released"]) == 3
388
389 def test_all_for_run_released_entries_have_required_keys(self, repo: pathlib.Path) -> None:
390 create_reservation(
391 repo, run_id="run-k", branch="main",
392 addresses=["a.py::b"], ttl_seconds=3600,
393 )
394 result = runner.invoke(
395 cli,
396 ["coord", "release", "--all-for-run", "run-k", "--run-id", "run-k", "--json"],
397 )
398 data = json.loads(result.output)
399 entry = data["released"][0]
400 assert "reservation_id" in entry
401 assert "run_id" in entry
402 assert "released_at" in entry
403 assert "reason" in entry
404
405 def test_all_for_run_only_targets_matching_run_id(self, repo: pathlib.Path) -> None:
406 res_a = create_reservation(
407 repo, run_id="run-a", branch="main",
408 addresses=["a.py::x"], ttl_seconds=3600,
409 )
410 create_reservation(
411 repo, run_id="run-b", branch="main",
412 addresses=["b.py::y"], ttl_seconds=3600,
413 )
414 runner.invoke(
415 cli,
416 ["coord", "release", "--all-for-run", "run-a", "--run-id", "run-a"],
417 )
418 released = load_released_ids(repo)
419 assert res_a.reservation_id in released
420 # run-b should not be released
421 all_res = load_all_reservations(repo)
422 run_b_ids = {r.reservation_id for r in all_res if r.run_id == "run-b"}
423 assert not run_b_ids & released
424
425 def test_all_for_run_with_reason_cancelled(self, repo: pathlib.Path) -> None:
426 create_reservation(
427 repo, run_id="run-c", branch="main",
428 addresses=["c.py::f"], ttl_seconds=3600,
429 )
430 result = runner.invoke(
431 cli,
432 [
433 "coord", "release", "--all-for-run", "run-c",
434 "--run-id", "run-c", "--reason", "cancelled", "--json",
435 ],
436 )
437 data = json.loads(result.output)
438 assert data["released"][0]["reason"] == "cancelled"
439
440
441 # ---------------------------------------------------------------------------
442 # Security
443 # ---------------------------------------------------------------------------
444
445
446 class TestReleaseSecurity:
447 def test_path_traversal_in_reservation_id_rejected(self, repo: pathlib.Path) -> None:
448 result = runner.invoke(
449 cli, ["coord", "release", "../../etc/passwd", "--run-id", "agent-1"]
450 )
451 assert result.exit_code != 0
452 # Must not have created any file outside coord dir
453 assert not (repo / "etc").exists()
454 assert not (repo.parent / "etc").exists()
455
456 def test_path_traversal_with_dots_rejected(self, repo: pathlib.Path) -> None:
457 result = runner.invoke(
458 cli, ["coord", "release", "../sneaky", "--run-id", "agent-1"]
459 )
460 assert result.exit_code != 0
461
462 def test_ansi_in_run_id_does_not_crash(self, repo: pathlib.Path, reservation: Reservation) -> None:
463 rid = reservation.reservation_id
464 ansi_run_id = "\x1b[31magent-evil\x1b[0m"
465 result = runner.invoke(
466 cli, ["coord", "release", rid, "--run-id", ansi_run_id]
467 )
468 # Should complete without crashing (exit 0 or 1 depending on sanitization)
469 # The important thing is it doesn't raise an unhandled exception
470 assert result.exit_code in (0, 1, 2)
471
472 def test_null_byte_in_reservation_id_rejected(self, repo: pathlib.Path) -> None:
473 result = runner.invoke(
474 cli, ["coord", "release", "abc\x00def", "--run-id", "agent-1"]
475 )
476 assert result.exit_code != 0
477
478
479 # ---------------------------------------------------------------------------
480 # Stress
481 # ---------------------------------------------------------------------------
482
483
484 class TestReleaseStress:
485 def test_200_reservations_all_for_run_under_3s(self, repo: pathlib.Path) -> None:
486 for i in range(200):
487 create_reservation(
488 repo,
489 run_id="stress-agent",
490 branch="feat/stress",
491 addresses=[f"src/stress_{i}.py::func"],
492 ttl_seconds=3600,
493 )
494 t0 = time.monotonic()
495 result = runner.invoke(
496 cli,
497 ["coord", "release", "--all-for-run", "stress-agent", "--run-id", "stress-agent"],
498 )
499 elapsed = time.monotonic() - t0
500 assert result.exit_code == 0, result.output
501 assert elapsed < 3.0, f"Batch release took {elapsed:.2f}s (> 3s limit)"
502 released = load_released_ids(repo)
503 all_res = load_all_reservations(repo)
504 stress_ids = {r.reservation_id for r in all_res if r.run_id == "stress-agent"}
505 assert len(stress_ids) == 200
506 assert stress_ids <= released
507
508 def test_50_concurrent_list_reads_do_not_corrupt(self, repo: pathlib.Path) -> None:
509 """Concurrent reads of the released-IDs set must never raise."""
510 for i in range(20):
511 res = create_reservation(
512 repo,
513 run_id="concurrent-read-agent",
514 branch="main",
515 addresses=[f"src/r{i}.py::f"],
516 ttl_seconds=3600,
517 )
518 create_release(repo, res.reservation_id, run_id="concurrent-read-agent")
519
520 errors: list[Exception] = []
521
522 def _reader() -> None:
523 try:
524 ids = load_released_ids(repo)
525 assert len(ids) >= 20
526 except Exception as exc: # noqa: BLE001
527 errors.append(exc)
528
529 threads = [threading.Thread(target=_reader) for _ in range(50)]
530 for t in threads:
531 t.start()
532 for t in threads:
533 t.join()
534 assert not errors, f"Concurrent read errors: {errors}"
535
536
537 # ---------------------------------------------------------------------------
538 # Input validation
539 # ---------------------------------------------------------------------------
540
541
542 class TestReleaseInputValidation:
543 def test_run_id_at_max_length_accepted(self, repo: pathlib.Path, reservation: Reservation) -> None:
544 rid = reservation.reservation_id
545 long_run_id = "x" * _MAX_RUN_ID_LEN
546 result = runner.invoke(
547 cli, ["coord", "release", rid, "--run-id", long_run_id]
548 )
549 assert result.exit_code == 0, result.output
550
551 def test_run_id_over_max_length_exits_user_error(self, repo: pathlib.Path) -> None:
552 too_long = "x" * (_MAX_RUN_ID_LEN + 1)
553 result = runner.invoke(
554 cli, ["coord", "release", "--run-id", too_long, "--all-for-run", "agent-1"]
555 )
556 assert result.exit_code == ExitCode.USER_ERROR
557
558 def test_run_id_over_max_length_message_on_stderr(self, repo: pathlib.Path) -> None:
559 too_long = "x" * (_MAX_RUN_ID_LEN + 1)
560 result = runner.invoke(
561 cli, ["coord", "release", "--run-id", too_long, "--all-for-run", "agent-1"]
562 )
563 combined = result.output + (result.stderr or "")
564 assert "run-id" in combined.lower() or "too long" in combined.lower()
565
566 def test_run_id_over_max_leaves_no_release_file(self, repo: pathlib.Path, reservation: Reservation) -> None:
567 """Validation must fire before any file I/O."""
568 too_long = "x" * (_MAX_RUN_ID_LEN + 1)
569 runner.invoke(
570 cli,
571 ["coord", "release", reservation.reservation_id, "--run-id", too_long],
572 )
573 released = load_released_ids(repo)
574 assert reservation.reservation_id not in released
575
576 def test_mutual_exclusion_exits_user_error(self, repo: pathlib.Path, reservation: Reservation) -> None:
577 rid = reservation.reservation_id
578 result = runner.invoke(
579 cli,
580 ["coord", "release", rid, "--run-id", "agent-1", "--all-for-run", "agent-1"],
581 )
582 assert result.exit_code == ExitCode.USER_ERROR
583
584 def test_missing_both_exits_user_error(self, repo: pathlib.Path) -> None:
585 result = runner.invoke(cli, ["coord", "release", "--run-id", "agent-1"])
586 assert result.exit_code == ExitCode.USER_ERROR
587
588 def test_invalid_uuid_exits_user_error(self, repo: pathlib.Path) -> None:
589 result = runner.invoke(
590 cli, ["coord", "release", "not-a-uuid", "--run-id", "agent-1"]
591 )
592 assert result.exit_code == ExitCode.USER_ERROR
593
594 def test_not_found_uuid_exits_not_found(self, repo: pathlib.Path) -> None:
595 nonexistent = str(uuid.uuid4())
596 result = runner.invoke(
597 cli, ["coord", "release", nonexistent, "--run-id", "agent-1"]
598 )
599 assert result.exit_code == ExitCode.NOT_FOUND
600
601 def test_not_found_uuid_json_status(self, repo: pathlib.Path) -> None:
602 nonexistent = str(uuid.uuid4())
603 result = runner.invoke(
604 cli, ["coord", "release", nonexistent, "--run-id", "agent-1", "--json"]
605 )
606 data = json.loads(result.output)
607 assert data["status"] == "not_found"
608 assert result.exit_code == ExitCode.NOT_FOUND
609
610 def test_invalid_reason_rejected_by_parser(self, repo: pathlib.Path, reservation: Reservation) -> None:
611 rid = reservation.reservation_id
612 result = runner.invoke(
613 cli, ["coord", "release", rid, "--run-id", "agent-1", "--reason", "bogus-reason"]
614 )
615 assert result.exit_code != 0
616
617
618 # ---------------------------------------------------------------------------
619 # JSON output format
620 # ---------------------------------------------------------------------------
621
622
623 class TestReleaseJsonFormat:
624 def test_single_release_json_is_compact(self, repo: pathlib.Path, reservation: Reservation) -> None:
625 """JSON output must be compact (no indent=2 pretty-printing)."""
626 rid = reservation.reservation_id
627 result = runner.invoke(
628 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
629 )
630 assert result.exit_code == 0, result.output
631 # Compact JSON has no newlines inside the object
632 assert "\n" not in result.output.strip()
633
634 def test_batch_json_is_compact(self, repo: pathlib.Path) -> None:
635 for i in range(3):
636 create_reservation(
637 repo, run_id="compact-agent", branch="main",
638 addresses=[f"src/c{i}.py::f"], ttl_seconds=3600,
639 )
640 result = runner.invoke(
641 cli,
642 ["coord", "release", "--all-for-run", "compact-agent",
643 "--run-id", "compact-agent", "--json"],
644 )
645 assert result.exit_code == 0, result.output
646 assert "\n" not in result.output.strip()
647
648 def test_already_released_json_released_at_is_null(
649 self, repo: pathlib.Path, reservation: Reservation
650 ) -> None:
651 rid = reservation.reservation_id
652 runner.invoke(cli, ["coord", "release", rid, "--run-id", "agent-1"])
653 result = runner.invoke(
654 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
655 )
656 data = json.loads(result.output)
657 assert data["status"] == "already_released"
658 assert data["released_at"] is None
659
660 def test_batch_json_skipped_already_released_count(self, repo: pathlib.Path) -> None:
661 reservations = [
662 create_reservation(
663 repo, run_id="skip-agent", branch="main",
664 addresses=[f"src/s{i}.py::f"], ttl_seconds=3600,
665 )
666 for i in range(4)
667 ]
668 # Pre-release 2 of the 4
669 for res in reservations[:2]:
670 create_release(repo, res.reservation_id, run_id="skip-agent")
671
672 result = runner.invoke(
673 cli,
674 ["coord", "release", "--all-for-run", "skip-agent",
675 "--run-id", "skip-agent", "--json"],
676 )
677 data = json.loads(result.output)
678 assert data["skipped_already_released"] == 2
679 assert len(data["released"]) == 2
680
681 def test_single_json_has_elapsed_seconds(self, repo: pathlib.Path, reservation: Reservation) -> None:
682 rid = reservation.reservation_id
683 result = runner.invoke(
684 cli, ["coord", "release", rid, "--run-id", "agent-1", "--json"]
685 )
686 data = json.loads(result.output)
687 assert isinstance(data["elapsed_seconds"], float)
688 assert data["elapsed_seconds"] >= 0.0
689
690 def test_batch_json_has_elapsed_seconds(self, repo: pathlib.Path) -> None:
691 create_reservation(
692 repo, run_id="elapsed-agent", branch="main",
693 addresses=["x.py::f"], ttl_seconds=3600,
694 )
695 result = runner.invoke(
696 cli,
697 ["coord", "release", "--all-for-run", "elapsed-agent",
698 "--run-id", "elapsed-agent", "--json"],
699 )
700 data = json.loads(result.output)
701 assert isinstance(data["elapsed_seconds"], float)
702
703
704 # ---------------------------------------------------------------------------
705 # Concurrent
706 # ---------------------------------------------------------------------------
707
708
709 class TestReleaseConcurrent:
710 def test_two_threads_race_to_release_same_reservation(
711 self, repo: pathlib.Path, reservation: Reservation
712 ) -> None:
713 """Both threads must exit 0; one writes the tombstone, the other sees already_released."""
714 rid = reservation.reservation_id
715 exit_codes: list[int] = []
716 lock = threading.Lock()
717
718 def _release() -> None:
719 result = runner.invoke(
720 cli, ["coord", "release", rid, "--run-id", "agent-race"]
721 )
722 with lock:
723 exit_codes.append(result.exit_code)
724
725 t1 = threading.Thread(target=_release)
726 t2 = threading.Thread(target=_release)
727 t1.start()
728 t2.start()
729 t1.join()
730 t2.join()
731
732 assert exit_codes == [0, 0], f"Expected both exits 0, got {exit_codes}"
733 released = load_released_ids(repo)
734 assert rid in released
735
736 def test_concurrent_batch_releases_idempotent(self, repo: pathlib.Path) -> None:
737 """Multiple agents calling --all-for-run concurrently produce the same final state."""
738 for i in range(10):
739 create_reservation(
740 repo, run_id="concurrent-batch", branch="main",
741 addresses=[f"src/cb{i}.py::f"], ttl_seconds=3600,
742 )
743
744 results: list[int] = []
745 lock = threading.Lock()
746
747 def _batch() -> None:
748 result = runner.invoke(
749 cli,
750 ["coord", "release", "--all-for-run", "concurrent-batch",
751 "--run-id", "concurrent-batch"],
752 )
753 with lock:
754 results.append(result.exit_code)
755
756 threads = [threading.Thread(target=_batch) for _ in range(5)]
757 for t in threads:
758 t.start()
759 for t in threads:
760 t.join()
761
762 assert all(c == 0 for c in results), f"Non-zero exit in concurrent batch: {results}"
763 released = load_released_ids(repo)
764 all_res = load_all_reservations(repo)
765 batch_ids = {r.reservation_id for r in all_res if r.run_id == "concurrent-batch"}
766 assert batch_ids == batch_ids & released
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