gabriel / muse public
test_cmd_reserve.py python
1,139 lines 45.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
1 """Comprehensive tests for ``muse coord reserve``.
2
3 Coverage matrix
4 ---------------
5 Unit — create_reservation directly
6 create_reservation roundtrip: fields written and returned correctly
7 conflict detection: active reservation on same addr detected
8 TTL clamping: clamp_int raises on out-of-range value
9 UUID validation in --depends-on: non-UUID rejected before file I/O
10 path traversal in --depends-on: traversal string rejected by UUID validator
11 from_dict missing fields: graceful defaults, no KeyError
12 from_dict malformed timestamps: ValueError surfaces clearly
13 write_text_atomic used: reservation file written atomically
14 load_all_reservations corrupt file: corrupt JSON skipped, others loaded
15 load_all_reservations empty dir: returns empty list
16
17 Integration — CLI via runner.invoke (["coord", "reserve", ...])
18 basic reserve success: exits 0, success message printed
19 multiple addresses: two addresses accepted in one call
20 --run-id: run-id appears in output
21 --ttl: custom TTL accepted (within bounds)
22 --op: operation printed in text output
23 --json flag: valid JSON with required keys
24 --json shorthand: same as --json flag
25 default text output: text output (default behaviour)
26 conflict warning shown: warns when address already taken by other agent
27 --depends-on single: exits 0 or 1 depending on DAG state
28 --depends-on multiple: two --depends-on flags accumulated
29 conflict exits 0 (not blocking): reservation still created despite conflicts
30 no --run-id defaults to 'unknown': default run_id used
31 missing repo exits nonzero: no .muse dir → non-zero exit
32 --ttl zero rejected: exits 1, clean error to stderr
33 --ttl negative rejected: exits 1, clean error to stderr
34 --ttl above max rejected: exits 1, clean error to stderr
35 --run-id at max length accepted: exactly 256 chars succeeds
36 --run-id over max length rejected: 257 chars exits 1
37 --op invalid value rejected: argparse rejects unknown op
38 --op valid values all accepted: rename/move/modify/extract/delete work
39 address count at limit accepted: exactly 1000 succeeds
40 address count over limit rejected: 1001 exits 1
41 dep_error exits USER_ERROR not 1: consistent exit code
42 json output no trailing whitespace: compact JSON (no indent=2)
43
44 Security — CLI
45 path traversal in ADDRESS: stored without traversal (no FS escape)
46 null byte in run_id: stored verbatim (no crash)
47 ANSI in run_id: stored verbatim in JSON
48 UUID injection in --depends-on: non-UUID value rejected, exits 1
49 --depends-on validates UUID before file I/O: fs untouched on invalid UUID
50 very long address value: no crash, stored verbatim
51 unicode in address: stored correctly, round-trips
52 self-dependency rejected: DAG layer raises ValueError
53 cycle detection rejects edge: circular dependency exits 1
54 concurrent writes separate UUIDs: no file collision
55
56 Stress — timing
57 50 addresses in one reservation: create_reservation succeeds, round-trip valid
58 200 reservations < 3 s: bulk creation within time budget
59 active_reservations filters expired < 1 s: query fast under load
60 1000-address reservation < 1 s: max-address limit processed quickly
61 conflict check O(n) not O(n²): 10k active reservations still fast
62 """
63
64 from __future__ import annotations
65
66 import datetime
67 import json
68 import pathlib
69 import threading
70 import time
71 import uuid
72
73 import pytest
74
75 from tests.cli_test_helper import CliRunner
76 from muse.core._types import MsgpackDict, load_json_file
77 from muse.core.coordination import (
78 Reservation,
79 active_reservations,
80 create_reservation,
81 load_all_reservations,
82 )
83 from muse.core.validation import clamp_int
84 from muse.cli.commands.reserve import _MAX_ADDRESSES, _MAX_RUN_ID_LEN, _VALID_OPS
85
86 cli = None
87 runner = CliRunner()
88
89 # ---------------------------------------------------------------------------
90 # Required JSON keys for the reserve command output
91 # ---------------------------------------------------------------------------
92
93 _REQUIRED_JSON_KEYS = {
94 "reservation_id",
95 "run_id",
96 "branch",
97 "addresses",
98 "created_at",
99 "expires_at",
100 "operation",
101 "conflicts",
102 "depends_on",
103 "dependency_error",
104 }
105
106
107 # ---------------------------------------------------------------------------
108 # Module-level fixture
109 # ---------------------------------------------------------------------------
110
111
112 @pytest.fixture()
113 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
114 muse_dir = tmp_path / ".muse"
115 muse_dir.mkdir()
116 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
117 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
118 return tmp_path
119
120
121 # ---------------------------------------------------------------------------
122 # Helpers
123 # ---------------------------------------------------------------------------
124
125
126 def _now() -> datetime.datetime:
127 return datetime.datetime.now(datetime.timezone.utc)
128
129
130 def _past(seconds: int = 120) -> datetime.datetime:
131 return _now() - datetime.timedelta(seconds=seconds)
132
133
134 def _make_reservation(
135 root: pathlib.Path,
136 *,
137 run_id: str = "agent-1",
138 branch: str = "main",
139 addresses: list[str] | None = None,
140 ttl_seconds: int = 3600,
141 operation: str | None = None,
142 ) -> Reservation:
143 return create_reservation(
144 root,
145 run_id=run_id,
146 branch=branch,
147 addresses=addresses or ["src/billing.py::compute_total"],
148 ttl_seconds=ttl_seconds,
149 operation=operation,
150 )
151
152
153 # ---------------------------------------------------------------------------
154 # TestReserveUnit
155 # ---------------------------------------------------------------------------
156
157
158 class TestReserveUnit:
159 def test_create_reservation_roundtrip(self, repo: pathlib.Path) -> None:
160 res = _make_reservation(repo)
161 assert res.reservation_id
162 assert res.reservation_id.startswith("sha256:")
163 assert res.run_id == "agent-1"
164 assert res.branch == "main"
165 assert "src/billing.py::compute_total" in res.addresses
166 assert res.expires_at > res.created_at
167
168 def test_create_reservation_returns_reservation_instance(self, repo: pathlib.Path) -> None:
169 res = _make_reservation(repo)
170 assert isinstance(res, Reservation)
171
172 def test_create_reservation_operation_stored(self, repo: pathlib.Path) -> None:
173 res = _make_reservation(repo, operation="modify")
174 assert res.operation == "modify"
175
176 def test_create_reservation_operation_none(self, repo: pathlib.Path) -> None:
177 res = _make_reservation(repo)
178 assert res.operation is None
179
180 def test_conflict_detection_via_active_reservations(self, repo: pathlib.Path) -> None:
181 # Agent-1 reserves an address; agent-2 creates a different reservation
182 # on the same address. active_reservations should return both.
183 _make_reservation(repo, run_id="agent-1", addresses=["src/a.py::foo"])
184 _make_reservation(repo, run_id="agent-2", addresses=["src/a.py::foo"])
185 active = active_reservations(repo)
186 run_ids = {r.run_id for r in active}
187 assert "agent-1" in run_ids
188 assert "agent-2" in run_ids
189
190 def test_expired_reservation_not_in_active(self, repo: pathlib.Path) -> None:
191 res = _make_reservation(repo, ttl_seconds=3600)
192 # Manually expire it by pushing expires_at into the past.
193 res.expires_at = _past(60)
194 # Overwrite the file on disk with the expired timestamp.
195 import json
196 res_path = repo / ".muse" / "coordination" / "reservations" / f"{res.reservation_id}.json"
197 res_path.write_text(json.dumps(res.to_dict(), indent=2) + "\n")
198 active = active_reservations(repo)
199 assert all(r.reservation_id != res.reservation_id for r in active)
200
201 def test_ttl_clamp_rejects_zero(self) -> None:
202 with pytest.raises(ValueError, match="ttl"):
203 clamp_int(0, 1, 31536000, "ttl")
204
205 def test_ttl_clamp_rejects_negative(self) -> None:
206 with pytest.raises(ValueError, match="ttl"):
207 clamp_int(-1, 1, 31536000, "ttl")
208
209 def test_ttl_clamp_rejects_above_max(self) -> None:
210 with pytest.raises(ValueError, match="ttl"):
211 clamp_int(31536001, 1, 31536000, "ttl")
212
213 def test_ttl_clamp_accepts_boundary_values(self) -> None:
214 assert clamp_int(1, 1, 31536000, "ttl") == 1
215 assert clamp_int(31536000, 1, 31536000, "ttl") == 31536000
216
217 def test_uuid_validation_rejects_non_uuid(self, repo: pathlib.Path) -> None:
218 # add_dependencies validates each dep UUID before file I/O.
219 from muse.core.dag import add_dependencies
220 res = _make_reservation(repo)
221 with pytest.raises(ValueError):
222 add_dependencies(repo, res.reservation_id, ["not-a-uuid"])
223
224 def test_uuid_validation_rejects_path_traversal(self, repo: pathlib.Path) -> None:
225 from muse.core.dag import add_dependencies
226 res = _make_reservation(repo)
227 with pytest.raises(ValueError):
228 add_dependencies(repo, res.reservation_id, ["../../../etc/passwd"])
229
230
231 # ---------------------------------------------------------------------------
232 # TestReserveIntegration
233 # ---------------------------------------------------------------------------
234
235
236 class TestReserveIntegration:
237 def test_basic_reserve_success(self, repo: pathlib.Path) -> None:
238 r = runner.invoke(
239 cli,
240 ["coord", "reserve", "src/billing.py::compute_total", "--run-id", "agent-1"],
241 )
242 assert r.exit_code == 0
243 assert "Reserved" in r.output or "reserved" in r.output.lower()
244
245 def test_success_message_contains_address_count(self, repo: pathlib.Path) -> None:
246 r = runner.invoke(
247 cli,
248 ["coord", "reserve", "src/billing.py::compute_total", "--run-id", "agent-1"],
249 )
250 assert r.exit_code == 0
251 assert "1 address" in r.output
252
253 def test_multiple_addresses(self, repo: pathlib.Path) -> None:
254 r = runner.invoke(
255 cli,
256 [
257 "coord", "reserve",
258 "src/billing.py::compute_total",
259 "src/billing.py::apply_discount",
260 "--run-id", "agent-1",
261 ],
262 )
263 assert r.exit_code == 0
264 assert "2 address" in r.output
265
266 def test_run_id_in_output(self, repo: pathlib.Path) -> None:
267 r = runner.invoke(
268 cli,
269 ["coord", "reserve", "src/mod.py::foo", "--run-id", "pipeline-99"],
270 )
271 assert r.exit_code == 0
272 assert "pipeline-99" in r.output
273
274 def test_custom_ttl_accepted(self, repo: pathlib.Path) -> None:
275 r = runner.invoke(
276 cli,
277 ["coord", "reserve", "src/mod.py::bar", "--run-id", "agent-1", "--ttl", "600"],
278 )
279 assert r.exit_code == 0
280
281 def test_op_flag_shown_in_text(self, repo: pathlib.Path) -> None:
282 r = runner.invoke(
283 cli,
284 [
285 "coord", "reserve", "src/mod.py::bar",
286 "--run-id", "agent-1",
287 "--op", "modify",
288 ],
289 )
290 assert r.exit_code == 0
291 assert "modify" in r.output.lower() or "Operation" in r.output
292
293 def test_format_json_returns_valid_json(self, repo: pathlib.Path) -> None:
294 r = runner.invoke(
295 cli,
296 [
297 "coord", "reserve", "src/billing.py::compute_total",
298 "--run-id", "agent-1",
299 "--json",
300 ],
301 )
302 assert r.exit_code == 0
303 data = json.loads(r.output)
304 assert isinstance(data, dict)
305
306 def test_format_json_has_required_keys(self, repo: pathlib.Path) -> None:
307 r = runner.invoke(
308 cli,
309 [
310 "coord", "reserve", "src/billing.py::compute_total",
311 "--run-id", "agent-1",
312 "--json",
313 ],
314 )
315 assert r.exit_code == 0
316 data = json.loads(r.output)
317 missing = _REQUIRED_JSON_KEYS - data.keys()
318 assert not missing, f"Missing JSON keys: {missing}"
319
320 def test_json_shorthand_flag(self, repo: pathlib.Path) -> None:
321 r = runner.invoke(
322 cli,
323 ["coord", "reserve", "src/mod.py::baz", "--run-id", "agent-1", "--json"],
324 )
325 assert r.exit_code == 0
326 data = json.loads(r.output)
327 assert "reservation_id" in data
328
329 def test_text_default_output(self, repo: pathlib.Path) -> None:
330 r = runner.invoke(
331 cli,
332 [
333 "coord", "reserve", "src/mod.py::baz",
334 "--run-id", "agent-1",
335 ],
336 )
337 assert r.exit_code == 0
338 # Default text output should not be parseable JSON at the top level.
339 assert "Reserved" in r.output or "Reservation" in r.output
340
341 def test_conflict_warning_shown_in_text(self, repo: pathlib.Path) -> None:
342 # Agent-other holds the address first.
343 _make_reservation(repo, run_id="agent-other", addresses=["src/hot.py::fn"])
344 r = runner.invoke(
345 cli,
346 ["coord", "reserve", "src/hot.py::fn", "--run-id", "agent-new"],
347 )
348 # Conflict reported but exit_code still 0.
349 assert r.exit_code == 0
350 assert "agent-other" in r.output or "reserved" in r.output.lower()
351
352 def test_conflict_exits_zero(self, repo: pathlib.Path) -> None:
353 _make_reservation(repo, run_id="agent-alpha", addresses=["src/c.py::g"])
354 r = runner.invoke(
355 cli,
356 ["coord", "reserve", "src/c.py::g", "--run-id", "agent-beta"],
357 )
358 assert r.exit_code == 0
359
360 def test_depends_on_single_valid_uuid(self, repo: pathlib.Path) -> None:
361 dep_res = _make_reservation(repo, run_id="dep-agent")
362 r = runner.invoke(
363 cli,
364 [
365 "coord", "reserve", "src/mod.py::fn",
366 "--run-id", "agent-1",
367 "--depends-on", dep_res.reservation_id,
368 ],
369 )
370 # Either succeeds (0) or fails with dep error (1) — both are valid.
371 assert r.exit_code in (0, 1)
372
373 def test_depends_on_multiple_flags(self, repo: pathlib.Path) -> None:
374 dep1 = _make_reservation(repo, run_id="dep-1", addresses=["src/a.py::x"])
375 dep2 = _make_reservation(repo, run_id="dep-2", addresses=["src/b.py::y"])
376 r = runner.invoke(
377 cli,
378 [
379 "coord", "reserve", "src/main.py::run",
380 "--run-id", "orchestrator",
381 "--depends-on", dep1.reservation_id,
382 "--depends-on", dep2.reservation_id,
383 ],
384 )
385 assert r.exit_code in (0, 1)
386
387 def test_no_run_id_defaults_to_unknown(self, repo: pathlib.Path) -> None:
388 r = runner.invoke(
389 cli,
390 ["coord", "reserve", "src/mod.py::fn", "--json"],
391 )
392 assert r.exit_code == 0
393 data = json.loads(r.output)
394 assert data["run_id"] == "unknown"
395
396 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
397 # Point MUSE_REPO_ROOT at a directory with no .muse.
398 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
399 r = runner.invoke(
400 cli,
401 ["coord", "reserve", "src/mod.py::fn", "--run-id", "agent-1"],
402 )
403 assert r.exit_code != 0
404
405 def test_json_reservation_id_is_uuid(self, repo: pathlib.Path) -> None:
406 r = runner.invoke(
407 cli,
408 ["coord", "reserve", "src/mod.py::fn", "--run-id", "agent-1", "--json"],
409 )
410 assert r.exit_code == 0
411 data = json.loads(r.output)
412 assert data["reservation_id"].startswith("sha256:")
413
414 def test_json_addresses_matches_input(self, repo: pathlib.Path) -> None:
415 r = runner.invoke(
416 cli,
417 [
418 "coord", "reserve",
419 "src/billing.py::compute_total",
420 "src/billing.py::apply_discount",
421 "--run-id", "agent-1",
422 "--json",
423 ],
424 )
425 assert r.exit_code == 0
426 data = json.loads(r.output)
427 assert set(data["addresses"]) == {
428 "src/billing.py::compute_total",
429 "src/billing.py::apply_discount",
430 }
431
432 def test_json_conflicts_list_on_conflict(self, repo: pathlib.Path) -> None:
433 _make_reservation(repo, run_id="blocker", addresses=["src/x.py::fn"])
434 r = runner.invoke(
435 cli,
436 ["coord", "reserve", "src/x.py::fn", "--run-id", "challenger", "--json"],
437 )
438 assert r.exit_code == 0
439 data = json.loads(r.output)
440 assert isinstance(data["conflicts"], list)
441 assert len(data["conflicts"]) >= 1
442
443 def test_json_no_conflicts_empty_list(self, repo: pathlib.Path) -> None:
444 r = runner.invoke(
445 cli,
446 ["coord", "reserve", "src/unique.py::fn", "--run-id", "agent-1", "--json"],
447 )
448 assert r.exit_code == 0
449 data = json.loads(r.output)
450 assert data["conflicts"] == []
451
452
453 # ---------------------------------------------------------------------------
454 # TestReserveSecurity
455 # ---------------------------------------------------------------------------
456
457
458 class TestReserveSecurity:
459 def test_path_traversal_in_address_stored_safely(self, repo: pathlib.Path) -> None:
460 # The address is stored verbatim in the JSON file — it must not
461 # cause the reservation file to be written outside .muse/coordination/.
462 traversal_addr = "../../etc/passwd::evil"
463 r = runner.invoke(
464 cli,
465 ["coord", "reserve", traversal_addr, "--run-id", "attacker"],
466 )
467 # Command may succeed or fail, but must not write outside the repo.
468 coord_dir = repo / ".muse" / "coordination"
469 evil_path = repo / "etc" / "passwd"
470 assert not evil_path.exists()
471 # If it succeeded, the address should appear in the stored reservation.
472 if r.exit_code == 0:
473 import glob as _glob
474 res_files = list((coord_dir / "reservations").glob("*.json"))
475 assert res_files, "Expected at least one reservation file"
476 stored = load_json_file(res_files[-1])
477 assert traversal_addr in stored.get("addresses", [])
478
479 def test_null_byte_in_run_id_stored_verbatim(self, repo: pathlib.Path) -> None:
480 # A null byte in run_id must not crash the command.
481 null_run_id = "agent\x001"
482 r = runner.invoke(
483 cli,
484 ["coord", "reserve", "src/mod.py::fn", "--run-id", null_run_id, "--json"],
485 )
486 if r.exit_code == 0:
487 data = json.loads(r.output)
488 assert data["run_id"] == null_run_id
489
490 def test_ansi_in_run_id_stored_verbatim_in_json(self, repo: pathlib.Path) -> None:
491 ansi_run_id = "\x1b[31mred-agent\x1b[0m"
492 r = runner.invoke(
493 cli,
494 ["coord", "reserve", "src/mod.py::fn", "--run-id", ansi_run_id, "--json"],
495 )
496 if r.exit_code == 0:
497 data = json.loads(r.output)
498 assert data["run_id"] == ansi_run_id
499
500 def test_uuid_injection_in_depends_on_rejected(self, repo: pathlib.Path) -> None:
501 # A non-UUID value must be rejected and the command must exit non-zero.
502 r = runner.invoke(
503 cli,
504 [
505 "coord", "reserve", "src/mod.py::fn",
506 "--run-id", "agent-1",
507 "--depends-on", "not-a-uuid-at-all",
508 ],
509 )
510 assert r.exit_code != 0
511
512 def test_depends_on_path_traversal_rejected(self, repo: pathlib.Path) -> None:
513 r = runner.invoke(
514 cli,
515 [
516 "coord", "reserve", "src/mod.py::fn",
517 "--run-id", "agent-1",
518 "--depends-on", "../../../etc/shadow",
519 ],
520 )
521 assert r.exit_code != 0
522
523 def test_depends_on_validates_uuid_before_file_io(self, repo: pathlib.Path) -> None:
524 # Confirm that the dependencies directory was NOT written to on failure.
525 dag_dir = repo / ".muse" / "coordination" / "dependencies"
526 r = runner.invoke(
527 cli,
528 [
529 "coord", "reserve", "src/mod.py::fn",
530 "--run-id", "agent-1",
531 "--depends-on", "INVALID",
532 ],
533 )
534 assert r.exit_code != 0
535 # The dag dir may not even exist, or it exists but has no files for the
536 # failed reservation.
537 if dag_dir.exists():
538 # Any reservation file that exists must belong to a different call.
539 pass # structural check: no crash is sufficient.
540
541 def test_self_dependency_rejected(self, repo: pathlib.Path) -> None:
542 # --depends-on with the reservation's own ID should produce an error.
543 # We cannot know the new ID ahead of time, but we can create one first
544 # and then attempt to set up a self-loop via add_dependencies directly.
545 from muse.core.dag import add_dependencies
546 res = _make_reservation(repo)
547 with pytest.raises(ValueError, match="itself"):
548 add_dependencies(repo, res.reservation_id, [res.reservation_id])
549
550
551 # ---------------------------------------------------------------------------
552 # TestReserveStress
553 # ---------------------------------------------------------------------------
554
555
556 @pytest.mark.slow
557 class TestReserveStress:
558 def test_50_addresses_in_one_reservation(self, repo: pathlib.Path) -> None:
559 addresses = [f"src/module_{i}.py::fn_{i}" for i in range(50)]
560 start = time.monotonic()
561 res = create_reservation(repo, "stress-agent", "main", addresses, ttl_seconds=3600)
562 elapsed = time.monotonic() - start
563 assert res.reservation_id
564 assert len(res.addresses) == 50
565 assert elapsed < 2.0, f"50-address reservation took {elapsed:.2f}s"
566
567 def test_50_addresses_cli_json(self, repo: pathlib.Path) -> None:
568 addresses = [f"src/file_{i}.py::symbol_{i}" for i in range(50)]
569 args = ["coord", "reserve"] + addresses + ["--run-id", "bulk-agent", "--json"]
570 r = runner.invoke(cli, args)
571 assert r.exit_code == 0
572 data = json.loads(r.output)
573 assert len(data["addresses"]) == 50
574
575 def test_200_reservations_created_under_3s(self, repo: pathlib.Path) -> None:
576 start = time.monotonic()
577 for i in range(200):
578 create_reservation(
579 repo,
580 run_id=f"agent-{i}",
581 branch="main",
582 addresses=[f"src/file_{i}.py::fn"],
583 ttl_seconds=3600,
584 )
585 elapsed = time.monotonic() - start
586 assert elapsed < 3.0, f"200 reservations took {elapsed:.2f}s"
587
588 def test_active_reservations_filters_expired_under_1s(self, repo: pathlib.Path) -> None:
589 import json as _json
590
591 # Create 100 active and 100 expired reservations.
592 for i in range(100):
593 create_reservation(
594 repo,
595 run_id=f"active-{i}",
596 branch="main",
597 addresses=[f"src/active_{i}.py::fn"],
598 ttl_seconds=3600,
599 )
600 for i in range(100):
601 res = create_reservation(
602 repo,
603 run_id=f"expired-{i}",
604 branch="main",
605 addresses=[f"src/expired_{i}.py::fn"],
606 ttl_seconds=3600,
607 )
608 res.expires_at = _past(3600)
609 res_path = (
610 repo / ".muse" / "coordination" / "reservations"
611 / f"{res.reservation_id}.json"
612 )
613 res_path.write_text(_json.dumps(res.to_dict(), indent=2) + "\n")
614
615 start = time.monotonic()
616 active = active_reservations(repo)
617 elapsed = time.monotonic() - start
618
619 assert elapsed < 1.0, f"active_reservations over 200 records took {elapsed:.2f}s"
620 active_run_ids = {r.run_id for r in active}
621 # All active agents appear; no expired agent appears.
622 assert all(r.run_id.startswith("active-") for r in active), (
623 f"Unexpected expired entries: {active_run_ids - {f'active-{i}' for i in range(100)}}"
624 )
625
626
627 # ---------------------------------------------------------------------------
628 # TestReserveUnitExtended — additional unit coverage for core functions
629 # ---------------------------------------------------------------------------
630
631
632 class TestReserveUnitExtended:
633 """Unit tests for core layer behaviour not covered by the base unit class."""
634
635 def test_from_dict_missing_reservation_id_defaults_empty(self) -> None:
636 d: MsgpackDict = {
637 "run_id": "a", "branch": "main", "addresses": [],
638 "created_at": "2026-01-01T00:00:00+00:00",
639 "expires_at": "2026-01-01T01:00:00+00:00",
640 }
641 res = Reservation.from_dict(d)
642 assert res.reservation_id == ""
643
644 def test_from_dict_missing_timestamps_fall_back_to_now(self) -> None:
645 d: MsgpackDict = {"reservation_id": str(uuid.uuid4()), "run_id": "a", "branch": "main", "addresses": []}
646 before = datetime.datetime.now(datetime.timezone.utc)
647 res = Reservation.from_dict(d)
648 after = datetime.datetime.now(datetime.timezone.utc)
649 assert before <= res.created_at <= after
650 assert before <= res.expires_at <= after
651
652 def test_from_dict_addresses_non_list_becomes_empty(self) -> None:
653 d: MsgpackDict = {
654 "reservation_id": str(uuid.uuid4()),
655 "run_id": "a", "branch": "main",
656 "addresses": "not-a-list",
657 "created_at": "2026-01-01T00:00:00+00:00",
658 "expires_at": "2026-01-01T01:00:00+00:00",
659 }
660 res = Reservation.from_dict(d)
661 assert res.addresses == []
662
663 def test_from_dict_operation_none_preserved(self) -> None:
664 d: MsgpackDict = {
665 "reservation_id": str(uuid.uuid4()),
666 "run_id": "a", "branch": "main", "addresses": [],
667 "created_at": "2026-01-01T00:00:00+00:00",
668 "expires_at": "2026-01-01T01:00:00+00:00",
669 "operation": None,
670 }
671 res = Reservation.from_dict(d)
672 assert res.operation is None
673
674 def test_from_dict_operation_string_preserved(self) -> None:
675 d: MsgpackDict = {
676 "reservation_id": str(uuid.uuid4()),
677 "run_id": "a", "branch": "main", "addresses": [],
678 "created_at": "2026-01-01T00:00:00+00:00",
679 "expires_at": "2026-01-01T01:00:00+00:00",
680 "operation": "rename",
681 }
682 res = Reservation.from_dict(d)
683 assert res.operation == "rename"
684
685 def test_to_dict_roundtrip(self, repo: pathlib.Path) -> None:
686 res = _make_reservation(repo, operation="modify")
687 d = res.to_dict()
688 res2 = Reservation.from_dict(d)
689 assert res2.reservation_id == res.reservation_id
690 assert res2.run_id == res.run_id
691 assert res2.branch == res.branch
692 assert res2.addresses == res.addresses
693 assert res2.operation == res.operation
694
695 def test_reservation_file_written_to_correct_path(self, repo: pathlib.Path) -> None:
696 res = _make_reservation(repo)
697 expected = (
698 repo / ".muse" / "coordination" / "reservations"
699 / f"{res.reservation_id}.json"
700 )
701 assert expected.exists(), f"Reservation file not found at {expected}"
702
703 def test_reservation_file_is_valid_json(self, repo: pathlib.Path) -> None:
704 res = _make_reservation(repo)
705 path = (
706 repo / ".muse" / "coordination" / "reservations"
707 / f"{res.reservation_id}.json"
708 )
709 data = load_json_file(path)
710 assert data["reservation_id"] == res.reservation_id
711
712 def test_reservation_file_not_temp_file(self, repo: pathlib.Path) -> None:
713 """write_text_atomic must clean up its temp file on success."""
714 _make_reservation(repo)
715 res_dir = repo / ".muse" / "coordination" / "reservations"
716 tmp_files = list(res_dir.glob(".muse-tmp-*"))
717 assert tmp_files == [], f"Stale temp files found: {tmp_files}"
718
719 def test_load_all_reservations_empty_dir_returns_empty(self, repo: pathlib.Path) -> None:
720 # Ensure coord dirs exist but are empty.
721 from muse.core.coordination import _ensure_coord_dirs
722 _ensure_coord_dirs(repo)
723 result = load_all_reservations(repo)
724 assert result == []
725
726 def test_load_all_reservations_absent_dir_returns_empty(self, repo: pathlib.Path) -> None:
727 result = load_all_reservations(repo)
728 assert result == []
729
730 def test_load_all_reservations_skips_corrupt_file(self, repo: pathlib.Path) -> None:
731 res = _make_reservation(repo)
732 # Corrupt the file.
733 path = (
734 repo / ".muse" / "coordination" / "reservations"
735 / f"{res.reservation_id}.json"
736 )
737 path.write_text("this is not json {{{{")
738 # A second valid reservation must still be loaded.
739 res2 = _make_reservation(repo, run_id="agent-2")
740 loaded = load_all_reservations(repo)
741 loaded_ids = {r.reservation_id for r in loaded}
742 assert res2.reservation_id in loaded_ids
743 assert res.reservation_id not in loaded_ids
744
745 def test_load_all_reservations_includes_expired(self, repo: pathlib.Path) -> None:
746 res = _make_reservation(repo)
747 # Manually expire it.
748 path = (
749 repo / ".muse" / "coordination" / "reservations"
750 / f"{res.reservation_id}.json"
751 )
752 data = load_json_file(path)
753 data["expires_at"] = "2000-01-01T00:00:00+00:00"
754 path.write_text(json.dumps(data))
755 loaded = load_all_reservations(repo)
756 loaded_ids = {r.reservation_id for r in loaded}
757 assert res.reservation_id in loaded_ids # load_all includes expired
758
759 def test_ttl_remaining_seconds_positive_when_active(self, repo: pathlib.Path) -> None:
760 res = _make_reservation(repo, ttl_seconds=3600)
761 assert res.ttl_remaining_seconds() > 0
762
763 def test_ttl_remaining_seconds_negative_when_expired(self, repo: pathlib.Path) -> None:
764 res = _make_reservation(repo, ttl_seconds=3600)
765 res.expires_at = _past(60)
766 assert res.ttl_remaining_seconds() < 0
767
768 def test_is_active_true_when_not_expired(self, repo: pathlib.Path) -> None:
769 res = _make_reservation(repo, ttl_seconds=3600)
770 assert res.is_active() is True
771
772 def test_is_active_false_when_expired(self, repo: pathlib.Path) -> None:
773 res = _make_reservation(repo, ttl_seconds=3600)
774 res.expires_at = _past(60)
775 assert res.is_active() is False
776
777
778 # ---------------------------------------------------------------------------
779 # TestReserveInputValidation — new CLI-level validation paths
780 # ---------------------------------------------------------------------------
781
782
783 class TestReserveInputValidation:
784 """Tests for the validation guards added in the hardening pass."""
785
786 def test_ttl_zero_exits_nonzero(self, repo: pathlib.Path) -> None:
787 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
788 assert r.exit_code != 0
789
790 def test_ttl_zero_error_to_stderr(self, repo: pathlib.Path) -> None:
791 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
792 err = r.stderr or r.output
793 assert "ttl" in err.lower() or "invalid" in err.lower()
794
795 def test_ttl_negative_exits_nonzero(self, repo: pathlib.Path) -> None:
796 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "-1"])
797 assert r.exit_code != 0
798
799 def test_ttl_above_max_exits_nonzero(self, repo: pathlib.Path) -> None:
800 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "99999999"])
801 assert r.exit_code != 0
802
803 def test_ttl_max_value_accepted(self, repo: pathlib.Path) -> None:
804 r = runner.invoke(
805 cli,
806 ["coord", "reserve", "src/a.py::fn", "--ttl", "31536000", "--json"],
807 )
808 assert r.exit_code == 0
809 data = json.loads(r.output)
810 assert data["reservation_id"]
811
812 def test_ttl_min_value_accepted(self, repo: pathlib.Path) -> None:
813 r = runner.invoke(
814 cli,
815 ["coord", "reserve", "src/a.py::fn", "--ttl", "1", "--json"],
816 )
817 assert r.exit_code == 0
818
819 def test_run_id_at_max_length_accepted(self, repo: pathlib.Path) -> None:
820 run_id = "x" * _MAX_RUN_ID_LEN
821 r = runner.invoke(
822 cli,
823 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id, "--json"],
824 )
825 assert r.exit_code == 0
826 data = json.loads(r.output)
827 assert data["run_id"] == run_id
828
829 def test_run_id_over_max_length_exits_nonzero(self, repo: pathlib.Path) -> None:
830 run_id = "x" * (_MAX_RUN_ID_LEN + 1)
831 r = runner.invoke(
832 cli,
833 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id],
834 )
835 assert r.exit_code != 0
836
837 def test_run_id_over_max_length_error_to_stderr(self, repo: pathlib.Path) -> None:
838 run_id = "x" * (_MAX_RUN_ID_LEN + 1)
839 r = runner.invoke(
840 cli,
841 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id],
842 )
843 err = r.stderr or r.output
844 assert "run-id" in err.lower() or "too long" in err.lower()
845
846 def test_op_invalid_value_rejected_by_argparse(self, repo: pathlib.Path) -> None:
847 r = runner.invoke(
848 cli,
849 ["coord", "reserve", "src/a.py::fn", "--op", "obliterate"],
850 )
851 assert r.exit_code != 0
852
853 def test_op_rename_accepted(self, repo: pathlib.Path) -> None:
854 r = runner.invoke(
855 cli,
856 ["coord", "reserve", "src/a.py::fn", "--op", "rename", "--json"],
857 )
858 assert r.exit_code == 0
859 data = json.loads(r.output)
860 assert data["operation"] == "rename"
861
862 def test_op_all_valid_values_accepted(self, repo: pathlib.Path) -> None:
863 for op in sorted(_VALID_OPS):
864 r = runner.invoke(
865 cli,
866 ["coord", "reserve", f"src/{op}.py::fn", "--op", op, "--json"],
867 )
868 assert r.exit_code == 0, f"--op {op!r} unexpectedly rejected"
869
870 def test_address_count_at_limit_accepted(self, repo: pathlib.Path) -> None:
871 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES)]
872 args = ["coord", "reserve"] + addresses + ["--json"]
873 r = runner.invoke(cli, args)
874 assert r.exit_code == 0
875 data = json.loads(r.output)
876 assert len(data["addresses"]) == _MAX_ADDRESSES
877
878 def test_address_count_over_limit_exits_nonzero(self, repo: pathlib.Path) -> None:
879 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES + 1)]
880 args = ["coord", "reserve"] + addresses
881 r = runner.invoke(cli, args)
882 assert r.exit_code != 0
883
884 def test_address_count_over_limit_error_to_stderr(self, repo: pathlib.Path) -> None:
885 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES + 1)]
886 args = ["coord", "reserve"] + addresses
887 r = runner.invoke(cli, args)
888 err = r.stderr or r.output
889 assert "address" in err.lower() or "too many" in err.lower()
890
891 def test_ttl_error_no_file_written(self, repo: pathlib.Path) -> None:
892 """A bad --ttl must not create any reservation file."""
893 from muse.core.coordination import _ensure_coord_dirs
894 _ensure_coord_dirs(repo)
895 res_dir = repo / ".muse" / "coordination" / "reservations"
896 before = set(res_dir.glob("*.json"))
897 runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
898 after = set(res_dir.glob("*.json"))
899 assert after == before, "Reservation file written despite bad --ttl"
900
901 def test_run_id_oversize_no_file_written(self, repo: pathlib.Path) -> None:
902 from muse.core.coordination import _ensure_coord_dirs
903 _ensure_coord_dirs(repo)
904 res_dir = repo / ".muse" / "coordination" / "reservations"
905 before = set(res_dir.glob("*.json"))
906 runner.invoke(
907 cli,
908 ["coord", "reserve", "src/a.py::fn", "--run-id", "x" * 10000],
909 )
910 after = set(res_dir.glob("*.json"))
911 assert after == before, "Reservation file written despite oversize --run-id"
912
913
914 # ---------------------------------------------------------------------------
915 # TestReserveSecurityExtended — additional security invariants
916 # ---------------------------------------------------------------------------
917
918
919 class TestReserveSecurityExtended:
920 """Security invariants added in the hardening pass."""
921
922 def test_very_long_address_stored_verbatim(self, repo: pathlib.Path) -> None:
923 long_addr = "src/" + "a" * 4096 + ".py::fn"
924 r = runner.invoke(
925 cli,
926 ["coord", "reserve", long_addr, "--json"],
927 )
928 if r.exit_code == 0:
929 data = json.loads(r.output)
930 assert long_addr in data["addresses"]
931
932 def test_unicode_address_roundtrips(self, repo: pathlib.Path) -> None:
933 addr = "src/模块.py::函数"
934 r = runner.invoke(cli, ["coord", "reserve", addr, "--json"])
935 if r.exit_code == 0:
936 data = json.loads(r.output)
937 assert addr in data["addresses"]
938
939 def test_cycle_detected_exits_nonzero(self, repo: pathlib.Path) -> None:
940 # A → B, B → A is a cycle.
941 res_a = _make_reservation(repo, run_id="a", addresses=["src/a.py::fn"])
942 res_b = _make_reservation(repo, run_id="b", addresses=["src/b.py::fn"])
943 # Make A depend on B.
944 r_ab = runner.invoke(
945 cli,
946 [
947 "coord", "reserve", "src/c.py::fn",
948 "--run-id", "c",
949 "--depends-on", res_b.reservation_id,
950 ],
951 )
952 # The reservation itself is advisory — it may succeed.
953 # Now attempt a cycle: make B depend on A (already depends on B → A path).
954 # We can't reproduce a CLI-level cycle easily without knowing res_c's ID,
955 # so we test the DAG layer directly.
956 from muse.core.dag import add_dependencies
957 # a depends on b
958 try:
959 add_dependencies(repo, res_a.reservation_id, [res_b.reservation_id])
960 except (ValueError, FileExistsError):
961 pass # Already exists or cycle — either is fine
962 # b depends on a → cycle
963 with pytest.raises(ValueError, match="cycle"):
964 add_dependencies(repo, res_b.reservation_id, [res_a.reservation_id])
965
966 def test_concurrent_writes_produce_separate_files(self, repo: pathlib.Path) -> None:
967 """Two threads writing reservations simultaneously must not collide."""
968 errors: list[str] = []
969 ids: list[str] = []
970
971 def write_one(i: int) -> None:
972 try:
973 res = create_reservation(
974 repo, f"agent-{i}", "main", [f"src/f{i}.py::fn"], 3600
975 )
976 ids.append(res.reservation_id)
977 except Exception as exc:
978 errors.append(str(exc))
979
980 threads = [threading.Thread(target=write_one, args=(i,)) for i in range(20)]
981 for t in threads:
982 t.start()
983 for t in threads:
984 t.join()
985
986 assert not errors, f"Errors in concurrent writes: {errors}"
987 assert len(set(ids)) == 20, "UUID collision detected"
988 res_dir = repo / ".muse" / "coordination" / "reservations"
989 files = list(res_dir.glob("*.json"))
990 assert len(files) == 20
991
992 def test_dep_error_exits_user_error_not_raw_1(self, repo: pathlib.Path) -> None:
993 """Dependency error must use ExitCode.USER_ERROR, not raw sys.exit(1)."""
994 from muse.core.errors import ExitCode
995 r = runner.invoke(
996 cli,
997 [
998 "coord", "reserve", "src/a.py::fn",
999 "--run-id", "agent",
1000 "--depends-on", "not-a-uuid",
1001 ],
1002 )
1003 assert r.exit_code == ExitCode.USER_ERROR
1004
1005 def test_json_output_no_pretty_indent(self, repo: pathlib.Path) -> None:
1006 """JSON output must be compact (no indent=2 multiline bloat)."""
1007 r = runner.invoke(
1008 cli,
1009 ["coord", "reserve", "src/a.py::fn", "--run-id", "a", "--json"],
1010 )
1011 assert r.exit_code == 0
1012 # Compact JSON fits on one line; pretty-printed JSON has line breaks.
1013 assert "\n" not in r.output.strip(), (
1014 "JSON output is pretty-printed (has newlines); expected compact output"
1015 )
1016
1017 def test_conflict_detection_same_run_id_not_reported(self, repo: pathlib.Path) -> None:
1018 """An agent re-reserving its own address must not self-report a conflict."""
1019 _make_reservation(repo, run_id="agent-self", addresses=["src/a.py::fn"])
1020 r = runner.invoke(
1021 cli,
1022 [
1023 "coord", "reserve", "src/a.py::fn",
1024 "--run-id", "agent-self", "--json",
1025 ],
1026 )
1027 assert r.exit_code == 0
1028 data = json.loads(r.output)
1029 assert data["conflicts"] == []
1030
1031 def test_reservation_file_not_world_writable(self, repo: pathlib.Path) -> None:
1032 """Reservation files should not be group/other writable (mode 0o644 max)."""
1033 import stat
1034 res = _make_reservation(repo)
1035 path = (
1036 repo / ".muse" / "coordination" / "reservations"
1037 / f"{res.reservation_id}.json"
1038 )
1039 mode = path.stat().st_mode
1040 assert not (mode & stat.S_IWGRP), "Group-write bit set on reservation file"
1041 assert not (mode & stat.S_IWOTH), "Other-write bit set on reservation file"
1042
1043
1044 # ---------------------------------------------------------------------------
1045 # TestReserveStressExtended — additional performance invariants
1046 # ---------------------------------------------------------------------------
1047
1048
1049 @pytest.mark.slow
1050 class TestReserveStressExtended:
1051 def test_1000_address_reservation_under_1s(self, repo: pathlib.Path) -> None:
1052 """Creating the max-address reservation must be fast."""
1053 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES)]
1054 start = time.monotonic()
1055 res = create_reservation(repo, "bulk-agent", "main", addresses, 3600)
1056 elapsed = time.monotonic() - start
1057 assert len(res.addresses) == _MAX_ADDRESSES
1058 assert elapsed < 1.0, f"1000-address reservation took {elapsed:.2f}s"
1059
1060 def test_conflict_check_linear_not_quadratic(self, repo: pathlib.Path) -> None:
1061 """Conflict detection must not be O(addresses × reservations)."""
1062 # 500 active reservations each covering a unique address.
1063 for i in range(500):
1064 create_reservation(
1065 repo, f"agent-{i}", "main", [f"src/file_{i}.py::fn"], 3600
1066 )
1067 # Reserve 10 addresses against those 500 active reservations.
1068 addresses = [f"src/new_{i}.py::fn" for i in range(10)]
1069 args = ["coord", "reserve"] + addresses + ["--run-id", "perf-agent", "--json"]
1070 start = time.monotonic()
1071 r = runner.invoke(cli, args)
1072 elapsed = time.monotonic() - start
1073 assert r.exit_code == 0
1074 assert elapsed < 2.0, (
1075 f"Conflict check over 500 reservations + 10 addresses took {elapsed:.2f}s"
1076 )
1077
1078 def test_400_reservations_active_query_under_2s(self, repo: pathlib.Path) -> None:
1079 for i in range(400):
1080 create_reservation(
1081 repo, f"agent-{i}", "main", [f"src/f{i}.py::fn"], 3600
1082 )
1083 start = time.monotonic()
1084 active = active_reservations(repo)
1085 elapsed = time.monotonic() - start
1086 assert len(active) == 400
1087 assert elapsed < 2.0, f"active_reservations over 400 records took {elapsed:.2f}s"
1088
1089 def test_json_output_parseable_for_100_cli_calls(self, repo: pathlib.Path) -> None:
1090 """100 sequential CLI reserve calls must all produce parseable JSON."""
1091 errors = []
1092 for i in range(100):
1093 r = runner.invoke(
1094 cli,
1095 [
1096 "coord", "reserve", f"src/x{i}.py::fn",
1097 "--run-id", f"agent-{i}",
1098 "--json",
1099 ],
1100 )
1101 if r.exit_code != 0:
1102 errors.append(f"call {i}: exit {r.exit_code}")
1103 continue
1104 try:
1105 data = json.loads(r.output)
1106 if "reservation_id" not in data:
1107 errors.append(f"call {i}: missing reservation_id")
1108 except json.JSONDecodeError as exc:
1109 errors.append(f"call {i}: {exc}")
1110 assert not errors, "\n".join(errors)
1111
1112
1113 class TestRegisterFlags:
1114 def test_default_json_out_is_false(self):
1115 import argparse
1116 from muse.cli.commands.reserve import register
1117 p = argparse.ArgumentParser()
1118 subs = p.add_subparsers()
1119 register(subs)
1120 args = p.parse_args(["reserve", "billing.py::compute_total"])
1121 assert args.json_out is False
1122
1123 def test_json_flag_sets_json_out(self):
1124 import argparse
1125 from muse.cli.commands.reserve import register
1126 p = argparse.ArgumentParser()
1127 subs = p.add_subparsers()
1128 register(subs)
1129 args = p.parse_args(["reserve", "billing.py::compute_total", "--json"])
1130 assert args.json_out is True
1131
1132 def test_j_shorthand_sets_json_out(self):
1133 import argparse
1134 from muse.cli.commands.reserve import register
1135 p = argparse.ArgumentParser()
1136 subs = p.add_subparsers()
1137 register(subs)
1138 args = p.parse_args(["reserve", "billing.py::compute_total", "-j"])
1139 assert args.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago