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