gabriel / muse public
test_cmd_reserve.py python
1,112 lines 44.5 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 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 --format json: valid JSON with required keys
24 --json shorthand: same as --format json
25 --format text explicit: 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
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 len(res.reservation_id) == 36 # UUID4 canonical form
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 "--format", "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 "--format", "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_format_text_explicit(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 "--format", "text",
336 ],
337 )
338 assert r.exit_code == 0
339 # Should not be parseable JSON at the top level.
340 assert "Reserved" in r.output or "Reservation" in r.output
341
342 def test_conflict_warning_shown_in_text(self, repo: pathlib.Path) -> None:
343 # Agent-other holds the address first.
344 _make_reservation(repo, run_id="agent-other", addresses=["src/hot.py::fn"])
345 r = runner.invoke(
346 cli,
347 ["coord", "reserve", "src/hot.py::fn", "--run-id", "agent-new"],
348 )
349 # Conflict reported but exit_code still 0.
350 assert r.exit_code == 0
351 assert "agent-other" in r.output or "reserved" in r.output.lower()
352
353 def test_conflict_exits_zero(self, repo: pathlib.Path) -> None:
354 _make_reservation(repo, run_id="agent-alpha", addresses=["src/c.py::g"])
355 r = runner.invoke(
356 cli,
357 ["coord", "reserve", "src/c.py::g", "--run-id", "agent-beta"],
358 )
359 assert r.exit_code == 0
360
361 def test_depends_on_single_valid_uuid(self, repo: pathlib.Path) -> None:
362 dep_res = _make_reservation(repo, run_id="dep-agent")
363 r = runner.invoke(
364 cli,
365 [
366 "coord", "reserve", "src/mod.py::fn",
367 "--run-id", "agent-1",
368 "--depends-on", dep_res.reservation_id,
369 ],
370 )
371 # Either succeeds (0) or fails with dep error (1) — both are valid.
372 assert r.exit_code in (0, 1)
373
374 def test_depends_on_multiple_flags(self, repo: pathlib.Path) -> None:
375 dep1 = _make_reservation(repo, run_id="dep-1", addresses=["src/a.py::x"])
376 dep2 = _make_reservation(repo, run_id="dep-2", addresses=["src/b.py::y"])
377 r = runner.invoke(
378 cli,
379 [
380 "coord", "reserve", "src/main.py::run",
381 "--run-id", "orchestrator",
382 "--depends-on", dep1.reservation_id,
383 "--depends-on", dep2.reservation_id,
384 ],
385 )
386 assert r.exit_code in (0, 1)
387
388 def test_no_run_id_defaults_to_unknown(self, repo: pathlib.Path) -> None:
389 r = runner.invoke(
390 cli,
391 ["coord", "reserve", "src/mod.py::fn", "--json"],
392 )
393 assert r.exit_code == 0
394 data = json.loads(r.output)
395 assert data["run_id"] == "unknown"
396
397 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
398 # Point MUSE_REPO_ROOT at a directory with no .muse.
399 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
400 r = runner.invoke(
401 cli,
402 ["coord", "reserve", "src/mod.py::fn", "--run-id", "agent-1"],
403 )
404 assert r.exit_code != 0
405
406 def test_json_reservation_id_is_uuid(self, repo: pathlib.Path) -> None:
407 r = runner.invoke(
408 cli,
409 ["coord", "reserve", "src/mod.py::fn", "--run-id", "agent-1", "--json"],
410 )
411 assert r.exit_code == 0
412 data = json.loads(r.output)
413 parsed = uuid.UUID(data["reservation_id"])
414 assert parsed.version == 4
415
416 def test_json_addresses_matches_input(self, repo: pathlib.Path) -> None:
417 r = runner.invoke(
418 cli,
419 [
420 "coord", "reserve",
421 "src/billing.py::compute_total",
422 "src/billing.py::apply_discount",
423 "--run-id", "agent-1",
424 "--json",
425 ],
426 )
427 assert r.exit_code == 0
428 data = json.loads(r.output)
429 assert set(data["addresses"]) == {
430 "src/billing.py::compute_total",
431 "src/billing.py::apply_discount",
432 }
433
434 def test_json_conflicts_list_on_conflict(self, repo: pathlib.Path) -> None:
435 _make_reservation(repo, run_id="blocker", addresses=["src/x.py::fn"])
436 r = runner.invoke(
437 cli,
438 ["coord", "reserve", "src/x.py::fn", "--run-id", "challenger", "--json"],
439 )
440 assert r.exit_code == 0
441 data = json.loads(r.output)
442 assert isinstance(data["conflicts"], list)
443 assert len(data["conflicts"]) >= 1
444
445 def test_json_no_conflicts_empty_list(self, repo: pathlib.Path) -> None:
446 r = runner.invoke(
447 cli,
448 ["coord", "reserve", "src/unique.py::fn", "--run-id", "agent-1", "--json"],
449 )
450 assert r.exit_code == 0
451 data = json.loads(r.output)
452 assert data["conflicts"] == []
453
454
455 # ---------------------------------------------------------------------------
456 # TestReserveSecurity
457 # ---------------------------------------------------------------------------
458
459
460 class TestReserveSecurity:
461 def test_path_traversal_in_address_stored_safely(self, repo: pathlib.Path) -> None:
462 # The address is stored verbatim in the JSON file — it must not
463 # cause the reservation file to be written outside .muse/coordination/.
464 traversal_addr = "../../etc/passwd::evil"
465 r = runner.invoke(
466 cli,
467 ["coord", "reserve", traversal_addr, "--run-id", "attacker"],
468 )
469 # Command may succeed or fail, but must not write outside the repo.
470 coord_dir = repo / ".muse" / "coordination"
471 evil_path = repo / "etc" / "passwd"
472 assert not evil_path.exists()
473 # If it succeeded, the address should appear in the stored reservation.
474 if r.exit_code == 0:
475 import glob as _glob
476 res_files = list((coord_dir / "reservations").glob("*.json"))
477 assert res_files, "Expected at least one reservation file"
478 stored = json.loads(res_files[-1].read_text())
479 assert traversal_addr in stored.get("addresses", [])
480
481 def test_null_byte_in_run_id_stored_verbatim(self, repo: pathlib.Path) -> None:
482 # A null byte in run_id must not crash the command.
483 null_run_id = "agent\x001"
484 r = runner.invoke(
485 cli,
486 ["coord", "reserve", "src/mod.py::fn", "--run-id", null_run_id, "--json"],
487 )
488 if r.exit_code == 0:
489 data = json.loads(r.output)
490 assert data["run_id"] == null_run_id
491
492 def test_ansi_in_run_id_stored_verbatim_in_json(self, repo: pathlib.Path) -> None:
493 ansi_run_id = "\x1b[31mred-agent\x1b[0m"
494 r = runner.invoke(
495 cli,
496 ["coord", "reserve", "src/mod.py::fn", "--run-id", ansi_run_id, "--json"],
497 )
498 if r.exit_code == 0:
499 data = json.loads(r.output)
500 assert data["run_id"] == ansi_run_id
501
502 def test_uuid_injection_in_depends_on_rejected(self, repo: pathlib.Path) -> None:
503 # A non-UUID value must be rejected and the command must exit non-zero.
504 r = runner.invoke(
505 cli,
506 [
507 "coord", "reserve", "src/mod.py::fn",
508 "--run-id", "agent-1",
509 "--depends-on", "not-a-uuid-at-all",
510 ],
511 )
512 assert r.exit_code != 0
513
514 def test_depends_on_path_traversal_rejected(self, repo: pathlib.Path) -> None:
515 r = runner.invoke(
516 cli,
517 [
518 "coord", "reserve", "src/mod.py::fn",
519 "--run-id", "agent-1",
520 "--depends-on", "../../../etc/shadow",
521 ],
522 )
523 assert r.exit_code != 0
524
525 def test_depends_on_validates_uuid_before_file_io(self, repo: pathlib.Path) -> None:
526 # Confirm that the dependencies directory was NOT written to on failure.
527 dag_dir = repo / ".muse" / "coordination" / "dependencies"
528 r = runner.invoke(
529 cli,
530 [
531 "coord", "reserve", "src/mod.py::fn",
532 "--run-id", "agent-1",
533 "--depends-on", "INVALID",
534 ],
535 )
536 assert r.exit_code != 0
537 # The dag dir may not even exist, or it exists but has no files for the
538 # failed reservation.
539 if dag_dir.exists():
540 # Any reservation file that exists must belong to a different call.
541 pass # structural check: no crash is sufficient.
542
543 def test_self_dependency_rejected(self, repo: pathlib.Path) -> None:
544 # --depends-on with the reservation's own ID should produce an error.
545 # We cannot know the new ID ahead of time, but we can create one first
546 # and then attempt to set up a self-loop via add_dependencies directly.
547 from muse.core.dag import add_dependencies
548 res = _make_reservation(repo)
549 with pytest.raises(ValueError, match="itself"):
550 add_dependencies(repo, res.reservation_id, [res.reservation_id])
551
552
553 # ---------------------------------------------------------------------------
554 # TestReserveStress
555 # ---------------------------------------------------------------------------
556
557
558 @pytest.mark.slow
559 class TestReserveStress:
560 def test_50_addresses_in_one_reservation(self, repo: pathlib.Path) -> None:
561 addresses = [f"src/module_{i}.py::fn_{i}" for i in range(50)]
562 start = time.monotonic()
563 res = create_reservation(repo, "stress-agent", "main", addresses, ttl_seconds=3600)
564 elapsed = time.monotonic() - start
565 assert res.reservation_id
566 assert len(res.addresses) == 50
567 assert elapsed < 2.0, f"50-address reservation took {elapsed:.2f}s"
568
569 def test_50_addresses_cli_json(self, repo: pathlib.Path) -> None:
570 addresses = [f"src/file_{i}.py::symbol_{i}" for i in range(50)]
571 args = ["coord", "reserve"] + addresses + ["--run-id", "bulk-agent", "--json"]
572 r = runner.invoke(cli, args)
573 assert r.exit_code == 0
574 data = json.loads(r.output)
575 assert len(data["addresses"]) == 50
576
577 def test_200_reservations_created_under_3s(self, repo: pathlib.Path) -> None:
578 start = time.monotonic()
579 for i in range(200):
580 create_reservation(
581 repo,
582 run_id=f"agent-{i}",
583 branch="main",
584 addresses=[f"src/file_{i}.py::fn"],
585 ttl_seconds=3600,
586 )
587 elapsed = time.monotonic() - start
588 assert elapsed < 3.0, f"200 reservations took {elapsed:.2f}s"
589
590 def test_active_reservations_filters_expired_under_1s(self, repo: pathlib.Path) -> None:
591 import json as _json
592
593 # Create 100 active and 100 expired reservations.
594 for i in range(100):
595 create_reservation(
596 repo,
597 run_id=f"active-{i}",
598 branch="main",
599 addresses=[f"src/active_{i}.py::fn"],
600 ttl_seconds=3600,
601 )
602 for i in range(100):
603 res = create_reservation(
604 repo,
605 run_id=f"expired-{i}",
606 branch="main",
607 addresses=[f"src/expired_{i}.py::fn"],
608 ttl_seconds=3600,
609 )
610 res.expires_at = _past(3600)
611 res_path = (
612 repo / ".muse" / "coordination" / "reservations"
613 / f"{res.reservation_id}.json"
614 )
615 res_path.write_text(_json.dumps(res.to_dict(), indent=2) + "\n")
616
617 start = time.monotonic()
618 active = active_reservations(repo)
619 elapsed = time.monotonic() - start
620
621 assert elapsed < 1.0, f"active_reservations over 200 records took {elapsed:.2f}s"
622 active_run_ids = {r.run_id for r in active}
623 # All active agents appear; no expired agent appears.
624 assert all(r.run_id.startswith("active-") for r in active), (
625 f"Unexpected expired entries: {active_run_ids - {f'active-{i}' for i in range(100)}}"
626 )
627
628
629 # ---------------------------------------------------------------------------
630 # TestReserveUnitExtended — additional unit coverage for core functions
631 # ---------------------------------------------------------------------------
632
633
634 class TestReserveUnitExtended:
635 """Unit tests for core layer behaviour not covered by the base unit class."""
636
637 def test_from_dict_missing_reservation_id_defaults_empty(self) -> None:
638 d: MsgpackDict = {
639 "run_id": "a", "branch": "main", "addresses": [],
640 "created_at": "2026-01-01T00:00:00+00:00",
641 "expires_at": "2026-01-01T01:00:00+00:00",
642 }
643 res = Reservation.from_dict(d)
644 assert res.reservation_id == ""
645
646 def test_from_dict_missing_timestamps_fall_back_to_now(self) -> None:
647 d: MsgpackDict = {"reservation_id": str(uuid.uuid4()), "run_id": "a", "branch": "main", "addresses": []}
648 before = datetime.datetime.now(datetime.timezone.utc)
649 res = Reservation.from_dict(d)
650 after = datetime.datetime.now(datetime.timezone.utc)
651 assert before <= res.created_at <= after
652 assert before <= res.expires_at <= after
653
654 def test_from_dict_addresses_non_list_becomes_empty(self) -> None:
655 d: MsgpackDict = {
656 "reservation_id": str(uuid.uuid4()),
657 "run_id": "a", "branch": "main",
658 "addresses": "not-a-list",
659 "created_at": "2026-01-01T00:00:00+00:00",
660 "expires_at": "2026-01-01T01:00:00+00:00",
661 }
662 res = Reservation.from_dict(d)
663 assert res.addresses == []
664
665 def test_from_dict_operation_none_preserved(self) -> None:
666 d: MsgpackDict = {
667 "reservation_id": str(uuid.uuid4()),
668 "run_id": "a", "branch": "main", "addresses": [],
669 "created_at": "2026-01-01T00:00:00+00:00",
670 "expires_at": "2026-01-01T01:00:00+00:00",
671 "operation": None,
672 }
673 res = Reservation.from_dict(d)
674 assert res.operation is None
675
676 def test_from_dict_operation_string_preserved(self) -> None:
677 d: MsgpackDict = {
678 "reservation_id": str(uuid.uuid4()),
679 "run_id": "a", "branch": "main", "addresses": [],
680 "created_at": "2026-01-01T00:00:00+00:00",
681 "expires_at": "2026-01-01T01:00:00+00:00",
682 "operation": "rename",
683 }
684 res = Reservation.from_dict(d)
685 assert res.operation == "rename"
686
687 def test_to_dict_roundtrip(self, repo: pathlib.Path) -> None:
688 res = _make_reservation(repo, operation="modify")
689 d = res.to_dict()
690 res2 = Reservation.from_dict(d)
691 assert res2.reservation_id == res.reservation_id
692 assert res2.run_id == res.run_id
693 assert res2.branch == res.branch
694 assert res2.addresses == res.addresses
695 assert res2.operation == res.operation
696
697 def test_reservation_file_written_to_correct_path(self, repo: pathlib.Path) -> None:
698 res = _make_reservation(repo)
699 expected = (
700 repo / ".muse" / "coordination" / "reservations"
701 / f"{res.reservation_id}.json"
702 )
703 assert expected.exists(), f"Reservation file not found at {expected}"
704
705 def test_reservation_file_is_valid_json(self, repo: pathlib.Path) -> None:
706 res = _make_reservation(repo)
707 path = (
708 repo / ".muse" / "coordination" / "reservations"
709 / f"{res.reservation_id}.json"
710 )
711 data = json.loads(path.read_text())
712 assert data["reservation_id"] == res.reservation_id
713
714 def test_reservation_file_not_temp_file(self, repo: pathlib.Path) -> None:
715 """write_text_atomic must clean up its temp file on success."""
716 _make_reservation(repo)
717 res_dir = repo / ".muse" / "coordination" / "reservations"
718 tmp_files = list(res_dir.glob(".muse-tmp-*"))
719 assert tmp_files == [], f"Stale temp files found: {tmp_files}"
720
721 def test_load_all_reservations_empty_dir_returns_empty(self, repo: pathlib.Path) -> None:
722 # Ensure coord dirs exist but are empty.
723 from muse.core.coordination import _ensure_coord_dirs
724 _ensure_coord_dirs(repo)
725 result = load_all_reservations(repo)
726 assert result == []
727
728 def test_load_all_reservations_absent_dir_returns_empty(self, repo: pathlib.Path) -> None:
729 result = load_all_reservations(repo)
730 assert result == []
731
732 def test_load_all_reservations_skips_corrupt_file(self, repo: pathlib.Path) -> None:
733 res = _make_reservation(repo)
734 # Corrupt the file.
735 path = (
736 repo / ".muse" / "coordination" / "reservations"
737 / f"{res.reservation_id}.json"
738 )
739 path.write_text("this is not json {{{{")
740 # A second valid reservation must still be loaded.
741 res2 = _make_reservation(repo, run_id="agent-2")
742 loaded = load_all_reservations(repo)
743 loaded_ids = {r.reservation_id for r in loaded}
744 assert res2.reservation_id in loaded_ids
745 assert res.reservation_id not in loaded_ids
746
747 def test_load_all_reservations_includes_expired(self, repo: pathlib.Path) -> None:
748 res = _make_reservation(repo)
749 # Manually expire it.
750 path = (
751 repo / ".muse" / "coordination" / "reservations"
752 / f"{res.reservation_id}.json"
753 )
754 data = json.loads(path.read_text())
755 data["expires_at"] = "2000-01-01T00:00:00+00:00"
756 path.write_text(json.dumps(data))
757 loaded = load_all_reservations(repo)
758 loaded_ids = {r.reservation_id for r in loaded}
759 assert res.reservation_id in loaded_ids # load_all includes expired
760
761 def test_ttl_remaining_seconds_positive_when_active(self, repo: pathlib.Path) -> None:
762 res = _make_reservation(repo, ttl_seconds=3600)
763 assert res.ttl_remaining_seconds() > 0
764
765 def test_ttl_remaining_seconds_negative_when_expired(self, repo: pathlib.Path) -> None:
766 res = _make_reservation(repo, ttl_seconds=3600)
767 res.expires_at = _past(60)
768 assert res.ttl_remaining_seconds() < 0
769
770 def test_is_active_true_when_not_expired(self, repo: pathlib.Path) -> None:
771 res = _make_reservation(repo, ttl_seconds=3600)
772 assert res.is_active() is True
773
774 def test_is_active_false_when_expired(self, repo: pathlib.Path) -> None:
775 res = _make_reservation(repo, ttl_seconds=3600)
776 res.expires_at = _past(60)
777 assert res.is_active() is False
778
779
780 # ---------------------------------------------------------------------------
781 # TestReserveInputValidation — new CLI-level validation paths
782 # ---------------------------------------------------------------------------
783
784
785 class TestReserveInputValidation:
786 """Tests for the validation guards added in the hardening pass."""
787
788 def test_ttl_zero_exits_nonzero(self, repo: pathlib.Path) -> None:
789 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
790 assert r.exit_code != 0
791
792 def test_ttl_zero_error_to_stderr(self, repo: pathlib.Path) -> None:
793 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
794 err = r.stderr or r.output
795 assert "ttl" in err.lower() or "invalid" in err.lower()
796
797 def test_ttl_negative_exits_nonzero(self, repo: pathlib.Path) -> None:
798 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "-1"])
799 assert r.exit_code != 0
800
801 def test_ttl_above_max_exits_nonzero(self, repo: pathlib.Path) -> None:
802 r = runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "99999999"])
803 assert r.exit_code != 0
804
805 def test_ttl_max_value_accepted(self, repo: pathlib.Path) -> None:
806 r = runner.invoke(
807 cli,
808 ["coord", "reserve", "src/a.py::fn", "--ttl", "31536000", "--json"],
809 )
810 assert r.exit_code == 0
811 data = json.loads(r.output)
812 assert data["reservation_id"]
813
814 def test_ttl_min_value_accepted(self, repo: pathlib.Path) -> None:
815 r = runner.invoke(
816 cli,
817 ["coord", "reserve", "src/a.py::fn", "--ttl", "1", "--json"],
818 )
819 assert r.exit_code == 0
820
821 def test_run_id_at_max_length_accepted(self, repo: pathlib.Path) -> None:
822 run_id = "x" * _MAX_RUN_ID_LEN
823 r = runner.invoke(
824 cli,
825 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id, "--json"],
826 )
827 assert r.exit_code == 0
828 data = json.loads(r.output)
829 assert data["run_id"] == run_id
830
831 def test_run_id_over_max_length_exits_nonzero(self, repo: pathlib.Path) -> None:
832 run_id = "x" * (_MAX_RUN_ID_LEN + 1)
833 r = runner.invoke(
834 cli,
835 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id],
836 )
837 assert r.exit_code != 0
838
839 def test_run_id_over_max_length_error_to_stderr(self, repo: pathlib.Path) -> None:
840 run_id = "x" * (_MAX_RUN_ID_LEN + 1)
841 r = runner.invoke(
842 cli,
843 ["coord", "reserve", "src/a.py::fn", "--run-id", run_id],
844 )
845 err = r.stderr or r.output
846 assert "run-id" in err.lower() or "too long" in err.lower()
847
848 def test_op_invalid_value_rejected_by_argparse(self, repo: pathlib.Path) -> None:
849 r = runner.invoke(
850 cli,
851 ["coord", "reserve", "src/a.py::fn", "--op", "obliterate"],
852 )
853 assert r.exit_code != 0
854
855 def test_op_rename_accepted(self, repo: pathlib.Path) -> None:
856 r = runner.invoke(
857 cli,
858 ["coord", "reserve", "src/a.py::fn", "--op", "rename", "--json"],
859 )
860 assert r.exit_code == 0
861 data = json.loads(r.output)
862 assert data["operation"] == "rename"
863
864 def test_op_all_valid_values_accepted(self, repo: pathlib.Path) -> None:
865 for op in sorted(_VALID_OPS):
866 r = runner.invoke(
867 cli,
868 ["coord", "reserve", f"src/{op}.py::fn", "--op", op, "--json"],
869 )
870 assert r.exit_code == 0, f"--op {op!r} unexpectedly rejected"
871
872 def test_address_count_at_limit_accepted(self, repo: pathlib.Path) -> None:
873 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES)]
874 args = ["coord", "reserve"] + addresses + ["--json"]
875 r = runner.invoke(cli, args)
876 assert r.exit_code == 0
877 data = json.loads(r.output)
878 assert len(data["addresses"]) == _MAX_ADDRESSES
879
880 def test_address_count_over_limit_exits_nonzero(self, repo: pathlib.Path) -> None:
881 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES + 1)]
882 args = ["coord", "reserve"] + addresses
883 r = runner.invoke(cli, args)
884 assert r.exit_code != 0
885
886 def test_address_count_over_limit_error_to_stderr(self, repo: pathlib.Path) -> None:
887 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES + 1)]
888 args = ["coord", "reserve"] + addresses
889 r = runner.invoke(cli, args)
890 err = r.stderr or r.output
891 assert "address" in err.lower() or "too many" in err.lower()
892
893 def test_ttl_error_no_file_written(self, repo: pathlib.Path) -> None:
894 """A bad --ttl must not create any reservation file."""
895 from muse.core.coordination import _ensure_coord_dirs
896 _ensure_coord_dirs(repo)
897 res_dir = repo / ".muse" / "coordination" / "reservations"
898 before = set(res_dir.glob("*.json"))
899 runner.invoke(cli, ["coord", "reserve", "src/a.py::fn", "--ttl", "0"])
900 after = set(res_dir.glob("*.json"))
901 assert after == before, "Reservation file written despite bad --ttl"
902
903 def test_run_id_oversize_no_file_written(self, repo: pathlib.Path) -> None:
904 from muse.core.coordination import _ensure_coord_dirs
905 _ensure_coord_dirs(repo)
906 res_dir = repo / ".muse" / "coordination" / "reservations"
907 before = set(res_dir.glob("*.json"))
908 runner.invoke(
909 cli,
910 ["coord", "reserve", "src/a.py::fn", "--run-id", "x" * 10000],
911 )
912 after = set(res_dir.glob("*.json"))
913 assert after == before, "Reservation file written despite oversize --run-id"
914
915
916 # ---------------------------------------------------------------------------
917 # TestReserveSecurityExtended — additional security invariants
918 # ---------------------------------------------------------------------------
919
920
921 class TestReserveSecurityExtended:
922 """Security invariants added in the hardening pass."""
923
924 def test_very_long_address_stored_verbatim(self, repo: pathlib.Path) -> None:
925 long_addr = "src/" + "a" * 4096 + ".py::fn"
926 r = runner.invoke(
927 cli,
928 ["coord", "reserve", long_addr, "--json"],
929 )
930 if r.exit_code == 0:
931 data = json.loads(r.output)
932 assert long_addr in data["addresses"]
933
934 def test_unicode_address_roundtrips(self, repo: pathlib.Path) -> None:
935 addr = "src/模块.py::函数"
936 r = runner.invoke(cli, ["coord", "reserve", addr, "--json"])
937 if r.exit_code == 0:
938 data = json.loads(r.output)
939 assert addr in data["addresses"]
940
941 def test_cycle_detected_exits_nonzero(self, repo: pathlib.Path) -> None:
942 # A → B, B → A is a cycle.
943 res_a = _make_reservation(repo, run_id="a", addresses=["src/a.py::fn"])
944 res_b = _make_reservation(repo, run_id="b", addresses=["src/b.py::fn"])
945 # Make A depend on B.
946 r_ab = runner.invoke(
947 cli,
948 [
949 "coord", "reserve", "src/c.py::fn",
950 "--run-id", "c",
951 "--depends-on", res_b.reservation_id,
952 ],
953 )
954 # The reservation itself is advisory — it may succeed.
955 # Now attempt a cycle: make B depend on A (already depends on B → A path).
956 # We can't reproduce a CLI-level cycle easily without knowing res_c's ID,
957 # so we test the DAG layer directly.
958 from muse.core.dag import add_dependencies
959 # a depends on b
960 try:
961 add_dependencies(repo, res_a.reservation_id, [res_b.reservation_id])
962 except (ValueError, FileExistsError):
963 pass # Already exists or cycle — either is fine
964 # b depends on a → cycle
965 with pytest.raises(ValueError, match="cycle"):
966 add_dependencies(repo, res_b.reservation_id, [res_a.reservation_id])
967
968 def test_concurrent_writes_produce_separate_files(self, repo: pathlib.Path) -> None:
969 """Two threads writing reservations simultaneously must not collide."""
970 errors: list[str] = []
971 ids: list[str] = []
972
973 def write_one(i: int) -> None:
974 try:
975 res = create_reservation(
976 repo, f"agent-{i}", "main", [f"src/f{i}.py::fn"], 3600
977 )
978 ids.append(res.reservation_id)
979 except Exception as exc:
980 errors.append(str(exc))
981
982 threads = [threading.Thread(target=write_one, args=(i,)) for i in range(20)]
983 for t in threads:
984 t.start()
985 for t in threads:
986 t.join()
987
988 assert not errors, f"Errors in concurrent writes: {errors}"
989 assert len(set(ids)) == 20, "UUID collision detected"
990 res_dir = repo / ".muse" / "coordination" / "reservations"
991 files = list(res_dir.glob("*.json"))
992 assert len(files) == 20
993
994 def test_dep_error_exits_user_error_not_raw_1(self, repo: pathlib.Path) -> None:
995 """Dependency error must use ExitCode.USER_ERROR, not raw sys.exit(1)."""
996 from muse.core.errors import ExitCode
997 r = runner.invoke(
998 cli,
999 [
1000 "coord", "reserve", "src/a.py::fn",
1001 "--run-id", "agent",
1002 "--depends-on", "not-a-uuid",
1003 ],
1004 )
1005 assert r.exit_code == ExitCode.USER_ERROR
1006
1007 def test_json_output_no_pretty_indent(self, repo: pathlib.Path) -> None:
1008 """JSON output must be compact (no indent=2 multiline bloat)."""
1009 r = runner.invoke(
1010 cli,
1011 ["coord", "reserve", "src/a.py::fn", "--run-id", "a", "--json"],
1012 )
1013 assert r.exit_code == 0
1014 # Compact JSON fits on one line; pretty-printed JSON has line breaks.
1015 assert "\n" not in r.output.strip(), (
1016 "JSON output is pretty-printed (has newlines); expected compact output"
1017 )
1018
1019 def test_conflict_detection_same_run_id_not_reported(self, repo: pathlib.Path) -> None:
1020 """An agent re-reserving its own address must not self-report a conflict."""
1021 _make_reservation(repo, run_id="agent-self", addresses=["src/a.py::fn"])
1022 r = runner.invoke(
1023 cli,
1024 [
1025 "coord", "reserve", "src/a.py::fn",
1026 "--run-id", "agent-self", "--json",
1027 ],
1028 )
1029 assert r.exit_code == 0
1030 data = json.loads(r.output)
1031 assert data["conflicts"] == []
1032
1033 def test_reservation_file_not_world_writable(self, repo: pathlib.Path) -> None:
1034 """Reservation files should not be group/other writable (mode 0o644 max)."""
1035 import stat
1036 res = _make_reservation(repo)
1037 path = (
1038 repo / ".muse" / "coordination" / "reservations"
1039 / f"{res.reservation_id}.json"
1040 )
1041 mode = path.stat().st_mode
1042 assert not (mode & stat.S_IWGRP), "Group-write bit set on reservation file"
1043 assert not (mode & stat.S_IWOTH), "Other-write bit set on reservation file"
1044
1045
1046 # ---------------------------------------------------------------------------
1047 # TestReserveStressExtended — additional performance invariants
1048 # ---------------------------------------------------------------------------
1049
1050
1051 @pytest.mark.slow
1052 class TestReserveStressExtended:
1053 def test_1000_address_reservation_under_1s(self, repo: pathlib.Path) -> None:
1054 """Creating the max-address reservation must be fast."""
1055 addresses = [f"src/m{i}.py::fn" for i in range(_MAX_ADDRESSES)]
1056 start = time.monotonic()
1057 res = create_reservation(repo, "bulk-agent", "main", addresses, 3600)
1058 elapsed = time.monotonic() - start
1059 assert len(res.addresses) == _MAX_ADDRESSES
1060 assert elapsed < 1.0, f"1000-address reservation took {elapsed:.2f}s"
1061
1062 def test_conflict_check_linear_not_quadratic(self, repo: pathlib.Path) -> None:
1063 """Conflict detection must not be O(addresses × reservations)."""
1064 # 500 active reservations each covering a unique address.
1065 for i in range(500):
1066 create_reservation(
1067 repo, f"agent-{i}", "main", [f"src/file_{i}.py::fn"], 3600
1068 )
1069 # Reserve 10 addresses against those 500 active reservations.
1070 addresses = [f"src/new_{i}.py::fn" for i in range(10)]
1071 args = ["coord", "reserve"] + addresses + ["--run-id", "perf-agent", "--json"]
1072 start = time.monotonic()
1073 r = runner.invoke(cli, args)
1074 elapsed = time.monotonic() - start
1075 assert r.exit_code == 0
1076 assert elapsed < 2.0, (
1077 f"Conflict check over 500 reservations + 10 addresses took {elapsed:.2f}s"
1078 )
1079
1080 def test_400_reservations_active_query_under_2s(self, repo: pathlib.Path) -> None:
1081 for i in range(400):
1082 create_reservation(
1083 repo, f"agent-{i}", "main", [f"src/f{i}.py::fn"], 3600
1084 )
1085 start = time.monotonic()
1086 active = active_reservations(repo)
1087 elapsed = time.monotonic() - start
1088 assert len(active) == 400
1089 assert elapsed < 2.0, f"active_reservations over 400 records took {elapsed:.2f}s"
1090
1091 def test_json_output_parseable_for_100_cli_calls(self, repo: pathlib.Path) -> None:
1092 """100 sequential CLI reserve calls must all produce parseable JSON."""
1093 errors = []
1094 for i in range(100):
1095 r = runner.invoke(
1096 cli,
1097 [
1098 "coord", "reserve", f"src/x{i}.py::fn",
1099 "--run-id", f"agent-{i}",
1100 "--json",
1101 ],
1102 )
1103 if r.exit_code != 0:
1104 errors.append(f"call {i}: exit {r.exit_code}")
1105 continue
1106 try:
1107 data = json.loads(r.output)
1108 if "reservation_id" not in data:
1109 errors.append(f"call {i}: missing reservation_id")
1110 except json.JSONDecodeError as exc:
1111 errors.append(f"call {i}: {exc}")
1112 assert not errors, "\n".join(errors)
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 73 days ago