gabriel / muse public
test_cmd_task_queue.py python
4,744 lines 195.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for muse coord task-queue: core + CLI (enqueue / claim / complete / fail-task /
2 cancel-task / tasks).
3
4 Coverage goals
5 --------------
6 * Unit — every public function in ``muse.core.task_queue``
7 * Integration — full lifecycle: enqueue → claim → complete/fail/cancel
8 * CLI — all six CLI subcommands via argparse dispatch and stdout capture
9 * Security — UUID validation, path traversal, ANSI injection, oversized inputs
10 * Stress — concurrent claiming correctness, large queue scanning
11
12 Test conventions
13 ----------------
14 * Every test receives a fresh ``tmp_path``-based repo fixture.
15 * Time is frozen via ``unittest.mock.patch`` on ``muse.core.task_queue._now_utc``
16 wherever predictable timestamps are required.
17 * CLI dispatch calls ``run_*`` directly (no subprocess overhead) with a
18 ``argparse.Namespace`` assembled by hand, capturing stdout/stderr via
19 ``capsys``.
20 """
21
22 from __future__ import annotations
23
24 import argparse
25 import datetime
26 import json
27 import os
28 import pathlib
29 import threading
30 import time
31 import uuid
32 from collections.abc import Generator
33 from contextlib import AbstractContextManager
34 from unittest.mock import MagicMock, patch
35
36 from muse.core._types import MsgpackValue, long_id
37
38 import pytest
39
40 from muse.core.task_queue import (
41 ClaimRecord,
42 TaskRecord,
43 _claims_dir,
44 _tasks_dir,
45 _try_excl_claim,
46 _try_optimistic_reclaim,
47 _validate_queue_name,
48 _validate_task_id,
49 cancel_task,
50 claim_next_task,
51 complete_task,
52 create_task,
53 ensure_task_dirs,
54 fail_task,
55 get_task_status,
56 heartbeat_claim,
57 load_all_claims,
58 load_all_tasks,
59 load_claim,
60 load_task,
61 )
62 from muse.cli.commands.task_queue import (
63 register_all,
64 run_cancel_task,
65 run_claim,
66 run_complete,
67 run_enqueue,
68 run_fail_task,
69 run_tasks,
70 )
71
72 # ── Fixtures ──────────────────────────────────────────────────────────────────
73
74 UTC = datetime.timezone.utc
75 _EPOCH = datetime.datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC)
76
77 VALID_UUID = long_id("a" * 64)
78 VALID_UUID2 = long_id("b" * 64)
79
80
81 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
82 """Return a minimal muse repo root with a ``.muse/`` directory."""
83 muse_dir = tmp_path / ".muse"
84 muse_dir.mkdir(parents=True)
85 return tmp_path
86
87
88 def _freeze(ts: datetime.datetime) -> AbstractContextManager[MagicMock]:
89 """Context manager: freeze ``muse.core.task_queue._now_utc`` to *ts*."""
90 return patch("muse.core.task_queue._now_utc", return_value=ts)
91
92
93 def _namespace(**kwargs: MsgpackValue) -> argparse.Namespace:
94 """Build an ``argparse.Namespace`` with sane defaults for CLI tests."""
95 defaults = {
96 "json_out": True,
97 "run_id": "agent-1",
98 "queue": None,
99 "title": "Test task",
100 "priority": 0,
101 "ttl_seconds": 86400,
102 "payload": "{}",
103 "tags": "",
104 "claim_ttl": 3600,
105 "wait": 0,
106 "task_id": VALID_UUID,
107 "result": "{}",
108 "error": "",
109 "force": False,
110 "status": None,
111 "limit": 200,
112 }
113 defaults.update(kwargs)
114 return argparse.Namespace(**defaults)
115
116
117 # ── Validation ─────────────────────────────────────────────────────────────────
118
119
120 class TestValidateTaskId:
121 """_validate_task_id must accept well-formed sha256: IDs and reject everything else."""
122
123 def test_accepts_valid_sha256_id(self) -> None:
124 _validate_task_id(VALID_UUID) # no exception
125
126 def test_rejects_empty(self) -> None:
127 with pytest.raises(ValueError):
128 _validate_task_id("")
129
130 def test_rejects_non_sha256(self) -> None:
131 with pytest.raises(ValueError):
132 _validate_task_id("not-a-sha256-id")
133
134 def test_rejects_path_traversal(self) -> None:
135 with pytest.raises(ValueError):
136 _validate_task_id("../../etc/passwd")
137
138 def test_rejects_null_bytes(self) -> None:
139 with pytest.raises(ValueError):
140 _validate_task_id("\x00" * 36)
141
142 def test_rejects_uuid(self) -> None:
143 with pytest.raises(ValueError):
144 _validate_task_id("12345678-1234-4abc-8abc-1234567890ab")
145
146 def test_rejects_sha256_with_slash(self) -> None:
147 with pytest.raises(ValueError):
148 _validate_task_id(long_id("a" * 63 + "/"))
149
150
151 class TestValidateQueueName:
152 """_validate_queue_name must accept valid names and reject bad ones."""
153
154 def test_accepts_simple(self) -> None:
155 _validate_queue_name("default")
156 _validate_queue_name("billing-queue")
157 _validate_queue_name("Agent_123")
158
159 def test_rejects_empty(self) -> None:
160 with pytest.raises(ValueError, match="non-empty"):
161 _validate_queue_name("")
162
163 def test_rejects_space(self) -> None:
164 with pytest.raises(ValueError):
165 _validate_queue_name("queue name")
166
167 def test_rejects_slash(self) -> None:
168 with pytest.raises(ValueError):
169 _validate_queue_name("../../etc")
170
171 def test_rejects_null_byte(self) -> None:
172 with pytest.raises(ValueError):
173 _validate_queue_name("queue\x00name")
174
175 def test_rejects_too_long(self) -> None:
176 with pytest.raises(ValueError, match="too long"):
177 _validate_queue_name("q" * 65)
178
179 def test_accepts_max_length(self) -> None:
180 _validate_queue_name("q" * 64)
181
182
183 # ── TaskRecord ─────────────────────────────────────────────────────────────────
184
185
186 class TestTaskRecord:
187 """TaskRecord serialisation round-trip and is_expired logic."""
188
189 def _make(self, **kwargs: MsgpackValue) -> TaskRecord:
190 defaults = dict(
191 task_id=VALID_UUID,
192 title="A task",
193 payload={"x": 1},
194 priority=0,
195 queue="default",
196 created_at=_EPOCH,
197 created_by="orchestrator",
198 ttl_seconds=3600,
199 tags=["a", "b"],
200 )
201 defaults.update(kwargs)
202 return TaskRecord(**defaults)
203
204 def test_to_dict_round_trip(self) -> None:
205 t = self._make()
206 d = t.to_dict()
207 t2 = TaskRecord.from_dict(d)
208 assert t2.task_id == t.task_id
209 assert t2.title == t.title
210 assert t2.priority == t.priority
211 assert t2.queue == t.queue
212 assert t2.tags == t.tags
213 assert t2.payload == t.payload
214
215 def test_is_expired_false_within_ttl(self) -> None:
216 t = self._make()
217 now = _EPOCH + datetime.timedelta(seconds=3599)
218 assert t.is_expired(now) is False
219
220 def test_is_expired_true_at_boundary(self) -> None:
221 t = self._make()
222 now = _EPOCH + datetime.timedelta(seconds=3600)
223 assert t.is_expired(now) is True
224
225 def test_from_dict_missing_created_at_defaults_to_now(self) -> None:
226 d = {"task_id": VALID_UUID, "title": "x"}
227 t = TaskRecord.from_dict(d)
228 assert isinstance(t.created_at, datetime.datetime)
229
230 def test_title_truncated_at_256(self) -> None:
231 d = {"task_id": VALID_UUID, "title": "x" * 300, "created_at": _EPOCH.isoformat()}
232 t = TaskRecord.from_dict(d)
233 assert len(t.title) <= 256
234
235
236 # ── ClaimRecord ────────────────────────────────────────────────────────────────
237
238
239 class TestClaimRecord:
240 """ClaimRecord serialisation round-trip and is_expired logic."""
241
242 def _make(self, **kwargs: MsgpackValue) -> ClaimRecord:
243 defaults = dict(
244 task_id=VALID_UUID,
245 claimer_run_id="agent-1",
246 claimed_at=_EPOCH,
247 expires_at=_EPOCH + datetime.timedelta(hours=1),
248 status="claimed",
249 heartbeat_at=_EPOCH,
250 claim_nonce=str(uuid.uuid4()),
251 result=None,
252 error=None,
253 )
254 defaults.update(kwargs)
255 return ClaimRecord(**defaults)
256
257 def test_to_dict_round_trip(self) -> None:
258 c = self._make()
259 d = c.to_dict()
260 c2 = ClaimRecord.from_dict(d)
261 assert c2.task_id == c.task_id
262 assert c2.claimer_run_id == c.claimer_run_id
263 assert c2.status == c.status
264 assert c2.claim_nonce == c.claim_nonce
265
266 def test_is_expired_false(self) -> None:
267 c = self._make()
268 assert c.is_expired(_EPOCH) is False
269
270 def test_is_expired_true(self) -> None:
271 c = self._make()
272 assert c.is_expired(_EPOCH + datetime.timedelta(hours=2)) is True
273
274
275 # ── get_task_status ────────────────────────────────────────────────────────────
276
277
278 class TestGetTaskStatus:
279 """Derives correct status from (task, claim, now) triple."""
280
281 def _task(self) -> TaskRecord:
282 return TaskRecord(
283 task_id=VALID_UUID, title="t", payload={}, priority=0, queue="default",
284 created_at=_EPOCH, created_by="x", ttl_seconds=3600, tags=[],
285 )
286
287 def _claim(self, **kw: MsgpackValue) -> ClaimRecord:
288 defaults = dict(
289 task_id=VALID_UUID, claimer_run_id="a", claimed_at=_EPOCH,
290 expires_at=_EPOCH + datetime.timedelta(hours=1), status="claimed",
291 heartbeat_at=_EPOCH, claim_nonce="nonce", result=None, error=None,
292 )
293 defaults.update(kw)
294 return ClaimRecord(**defaults)
295
296 def test_no_claim_is_pending(self) -> None:
297 assert get_task_status(self._task(), None, _EPOCH) == "pending"
298
299 def test_active_claim_is_claimed(self) -> None:
300 c = self._claim()
301 assert get_task_status(self._task(), c, _EPOCH) == "claimed"
302
303 def test_expired_claim_is_timed_out(self) -> None:
304 c = self._claim()
305 now = _EPOCH + datetime.timedelta(hours=2)
306 assert get_task_status(self._task(), c, now) == "timed_out"
307
308 def test_completed_status_passes_through(self) -> None:
309 c = self._claim(status="completed")
310 assert get_task_status(self._task(), c, _EPOCH) == "completed"
311
312 def test_failed_status_passes_through(self) -> None:
313 c = self._claim(status="failed")
314 assert get_task_status(self._task(), c, _EPOCH) == "failed"
315
316 def test_cancelled_status_passes_through(self) -> None:
317 c = self._claim(status="cancelled")
318 assert get_task_status(self._task(), c, _EPOCH) == "cancelled"
319
320
321 # ── ensure_task_dirs ───────────────────────────────────────────────────────────
322
323
324 class TestEnsureTaskDirs:
325 """ensure_task_dirs creates both directories idempotently."""
326
327 def test_creates_directories(self, tmp_path: pathlib.Path) -> None:
328 repo = _make_repo(tmp_path)
329 ensure_task_dirs(repo)
330 assert _tasks_dir(repo).is_dir()
331 assert _claims_dir(repo).is_dir()
332
333 def test_idempotent(self, tmp_path: pathlib.Path) -> None:
334 repo = _make_repo(tmp_path)
335 ensure_task_dirs(repo)
336 ensure_task_dirs(repo) # must not raise
337
338
339 # ── create_task ────────────────────────────────────────────────────────────────
340
341
342 class TestCreateTask:
343 """create_task validates inputs and persists a TaskRecord."""
344
345 def test_creates_file_on_disk(self, tmp_path: pathlib.Path) -> None:
346 repo = _make_repo(tmp_path)
347 with _freeze(_EPOCH):
348 t = create_task(repo, "Do X")
349 task_file = _tasks_dir(repo) / f"{t.task_id}.json"
350 assert task_file.is_file()
351
352 def test_returns_correct_fields(self, tmp_path: pathlib.Path) -> None:
353 repo = _make_repo(tmp_path)
354 with _freeze(_EPOCH):
355 t = create_task(
356 repo, "Deploy service",
357 payload={"env": "prod"},
358 priority=5,
359 queue="deploy",
360 ttl_seconds=7200,
361 created_by="ops",
362 tags=["prod", "critical"],
363 )
364 assert t.title == "Deploy service"
365 assert t.priority == 5
366 assert t.queue == "deploy"
367 assert t.ttl_seconds == 7200
368 assert t.created_by == "ops"
369 assert "prod" in t.tags
370 assert t.payload == {"env": "prod"}
371
372 def test_file_is_valid_json(self, tmp_path: pathlib.Path) -> None:
373 repo = _make_repo(tmp_path)
374 t = create_task(repo, "Validate JSON")
375 content = (_tasks_dir(repo) / f"{t.task_id}.json").read_text()
376 d = json.loads(content)
377 assert d["task_id"] == t.task_id
378
379 def test_empty_title_raises(self, tmp_path: pathlib.Path) -> None:
380 repo = _make_repo(tmp_path)
381 with pytest.raises(ValueError, match="non-empty"):
382 create_task(repo, "")
383
384 def test_invalid_queue_raises(self, tmp_path: pathlib.Path) -> None:
385 repo = _make_repo(tmp_path)
386 with pytest.raises(ValueError):
387 create_task(repo, "x", queue="bad queue!")
388
389 def test_tags_capped_at_32(self, tmp_path: pathlib.Path) -> None:
390 repo = _make_repo(tmp_path)
391 t = create_task(repo, "Lots of tags", tags=[f"tag{i}" for i in range(50)])
392 assert len(t.tags) == 32
393
394 def test_ttl_min_1(self, tmp_path: pathlib.Path) -> None:
395 repo = _make_repo(tmp_path)
396 t = create_task(repo, "Short TTL", ttl_seconds=0)
397 assert t.ttl_seconds >= 1
398
399
400 # ── load_all_tasks / load_task ─────────────────────────────────────────────────
401
402
403 class TestLoadTasks:
404 """Scanning and loading task records from the tasks directory."""
405
406 def test_empty_dir_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
407 repo = _make_repo(tmp_path)
408 assert load_all_tasks(repo) == []
409
410 def test_non_existent_dir_returns_empty_list(self, tmp_path: pathlib.Path) -> None:
411 repo = _make_repo(tmp_path)
412 assert load_all_tasks(repo) == []
413
414 def test_loads_created_task(self, tmp_path: pathlib.Path) -> None:
415 repo = _make_repo(tmp_path)
416 t = create_task(repo, "A task")
417 tasks = load_all_tasks(repo)
418 assert len(tasks) == 1
419 assert tasks[0].task_id == t.task_id
420
421 def test_skips_corrupt_file(self, tmp_path: pathlib.Path) -> None:
422 repo = _make_repo(tmp_path)
423 ensure_task_dirs(repo)
424 corrupt = _tasks_dir(repo) / f"{VALID_UUID}.json"
425 corrupt.write_text("NOT JSON")
426 tasks = load_all_tasks(repo)
427 assert tasks == []
428
429 def test_load_task_by_id(self, tmp_path: pathlib.Path) -> None:
430 repo = _make_repo(tmp_path)
431 t = create_task(repo, "Named task")
432 loaded = load_task(repo, t.task_id)
433 assert loaded is not None
434 assert loaded.title == "Named task"
435
436 def test_load_task_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
437 repo = _make_repo(tmp_path)
438 ensure_task_dirs(repo)
439 assert load_task(repo, VALID_UUID) is None
440
441 def test_load_task_invalid_id_raises(self, tmp_path: pathlib.Path) -> None:
442 repo = _make_repo(tmp_path)
443 with pytest.raises(ValueError):
444 load_task(repo, "not-a-uuid")
445
446
447 # ── _try_excl_claim ────────────────────────────────────────────────────────────
448
449
450 class TestTryExclClaim:
451 """O_CREAT|O_EXCL atomic claiming primitive."""
452
453 def test_first_claim_succeeds(self, tmp_path: pathlib.Path) -> None:
454 repo = _make_repo(tmp_path)
455 ensure_task_dirs(repo)
456 with _freeze(_EPOCH):
457 result = _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 3600)
458 assert result is not None
459 assert result.claimer_run_id == "agent-1"
460 assert result.status == "claimed"
461
462 def test_second_claim_returns_none(self, tmp_path: pathlib.Path) -> None:
463 repo = _make_repo(tmp_path)
464 ensure_task_dirs(repo)
465 with _freeze(_EPOCH):
466 first = _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 3600)
467 second = _try_excl_claim(repo, VALID_UUID, "agent-2", _EPOCH, 3600)
468 assert first is not None
469 assert second is None
470
471 def test_claim_file_is_written(self, tmp_path: pathlib.Path) -> None:
472 repo = _make_repo(tmp_path)
473 ensure_task_dirs(repo)
474 with _freeze(_EPOCH):
475 _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 3600)
476 claim_file = _claims_dir(repo) / f"{VALID_UUID}.json"
477 assert claim_file.is_file()
478
479 def test_claim_file_is_valid_json(self, tmp_path: pathlib.Path) -> None:
480 repo = _make_repo(tmp_path)
481 ensure_task_dirs(repo)
482 with _freeze(_EPOCH):
483 claim = _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 3600)
484 content = (_claims_dir(repo) / f"{VALID_UUID}.json").read_text()
485 d = json.loads(content)
486 assert d["claim_nonce"] == claim.claim_nonce
487
488 def test_expires_at_correct(self, tmp_path: pathlib.Path) -> None:
489 repo = _make_repo(tmp_path)
490 ensure_task_dirs(repo)
491 with _freeze(_EPOCH):
492 claim = _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 7200)
493 expected = _EPOCH + datetime.timedelta(seconds=7200)
494 assert claim.expires_at == expected
495
496
497 # ── _try_optimistic_reclaim ────────────────────────────────────────────────────
498
499
500 class TestTryOptimisticReclaim:
501 """Reclaim timed-out tasks via atomic rename + nonce verification."""
502
503 def test_reclaim_wins_when_no_competition(self, tmp_path: pathlib.Path) -> None:
504 repo = _make_repo(tmp_path)
505 ensure_task_dirs(repo)
506 # First claim
507 with _freeze(_EPOCH):
508 _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 1)
509 # Now reclaim at t+2 (expired)
510 now = _EPOCH + datetime.timedelta(seconds=2)
511 with _freeze(now):
512 result = _try_optimistic_reclaim(repo, VALID_UUID, "agent-2", now, 3600)
513 assert result is not None
514 assert result.claimer_run_id == "agent-2"
515
516 def test_nonce_written_to_file(self, tmp_path: pathlib.Path) -> None:
517 repo = _make_repo(tmp_path)
518 ensure_task_dirs(repo)
519 with _freeze(_EPOCH):
520 _try_excl_claim(repo, VALID_UUID, "agent-1", _EPOCH, 1)
521 now = _EPOCH + datetime.timedelta(seconds=2)
522 with _freeze(now):
523 result = _try_optimistic_reclaim(repo, VALID_UUID, "agent-2", now, 3600)
524 if result: # may fail under extreme race — just check if written
525 content = json.loads((_claims_dir(repo) / f"{VALID_UUID}.json").read_text())
526 assert content["claim_nonce"] == result.claim_nonce
527
528
529 # ── claim_next_task ────────────────────────────────────────────────────────────
530
531
532 class TestClaimNextTask:
533 """High-level claim_next_task: priority ordering, queue filtering, expiry re-claim."""
534
535 def test_returns_none_on_empty_queue(self, tmp_path: pathlib.Path) -> None:
536 repo = _make_repo(tmp_path)
537 with _freeze(_EPOCH):
538 result = claim_next_task(repo, "agent-1")
539 assert result is None
540
541 def test_claims_only_task(self, tmp_path: pathlib.Path) -> None:
542 repo = _make_repo(tmp_path)
543 with _freeze(_EPOCH):
544 t = create_task(repo, "Only task")
545 result = claim_next_task(repo, "agent-1")
546 assert result is not None
547 task, claim = result
548 assert task.task_id == t.task_id
549 assert claim.claimer_run_id == "agent-1"
550
551 def test_higher_priority_claimed_first(self, tmp_path: pathlib.Path) -> None:
552 repo = _make_repo(tmp_path)
553 with _freeze(_EPOCH):
554 low = create_task(repo, "Low priority", priority=0)
555 high = create_task(repo, "High priority", priority=10)
556 result = claim_next_task(repo, "agent-1")
557 assert result is not None
558 task, _ = result
559 assert task.task_id == high.task_id
560
561 def test_fifo_within_same_priority(self, tmp_path: pathlib.Path) -> None:
562 repo = _make_repo(tmp_path)
563 with _freeze(_EPOCH):
564 first = create_task(repo, "First task", priority=5)
565 with _freeze(_EPOCH + datetime.timedelta(seconds=1)):
566 _second = create_task(repo, "Second task", priority=5)
567 with _freeze(_EPOCH + datetime.timedelta(seconds=2)):
568 result = claim_next_task(repo, "agent-1")
569 assert result is not None
570 task, _ = result
571 assert task.task_id == first.task_id
572
573 def test_queue_filter_respected(self, tmp_path: pathlib.Path) -> None:
574 repo = _make_repo(tmp_path)
575 with _freeze(_EPOCH):
576 billing = create_task(repo, "Billing job", queue="billing")
577 _ops = create_task(repo, "Ops job", queue="ops")
578 result = claim_next_task(repo, "agent-1", queue="billing")
579 assert result is not None
580 task, _ = result
581 assert task.task_id == billing.task_id
582
583 def test_queue_filter_returns_none_when_no_match(self, tmp_path: pathlib.Path) -> None:
584 repo = _make_repo(tmp_path)
585 with _freeze(_EPOCH):
586 _ops = create_task(repo, "Ops job", queue="ops")
587 result = claim_next_task(repo, "agent-1", queue="billing")
588 assert result is None
589
590 def test_already_claimed_task_not_re_claimed(self, tmp_path: pathlib.Path) -> None:
591 repo = _make_repo(tmp_path)
592 with _freeze(_EPOCH):
593 _t = create_task(repo, "Unique task")
594 claim_next_task(repo, "agent-1")
595 result2 = claim_next_task(repo, "agent-2")
596 assert result2 is None
597
598 def test_expired_task_ttl_skipped(self, tmp_path: pathlib.Path) -> None:
599 repo = _make_repo(tmp_path)
600 with _freeze(_EPOCH):
601 _t = create_task(repo, "Expired task", ttl_seconds=10)
602 future = _EPOCH + datetime.timedelta(seconds=20)
603 with _freeze(future):
604 result = claim_next_task(repo, "agent-1")
605 assert result is None
606
607 def test_reclaims_timed_out_task(self, tmp_path: pathlib.Path) -> None:
608 repo = _make_repo(tmp_path)
609 with _freeze(_EPOCH):
610 _t = create_task(repo, "Timed-out task", ttl_seconds=86400)
611 claim_next_task(repo, "agent-1", claim_ttl_seconds=10)
612 # Advance past claim TTL
613 future = _EPOCH + datetime.timedelta(seconds=20)
614 with _freeze(future):
615 result = claim_next_task(repo, "agent-2", claim_ttl_seconds=3600)
616 assert result is not None
617 _, claim = result
618 assert claim.claimer_run_id == "agent-2"
619
620
621 # ── complete_task ──────────────────────────────────────────────────────────────
622
623
624 class TestCompleteTask:
625 """complete_task updates status, validates ownership."""
626
627 def _enqueue_and_claim(self, repo: pathlib.Path, run_id: str = "agent-1") -> tuple[TaskRecord, ClaimRecord]:
628 t = create_task(repo, "Task")
629 with _freeze(_EPOCH):
630 result = claim_next_task(repo, run_id)
631 assert result is not None
632 return result
633
634 def test_completes_successfully(self, tmp_path: pathlib.Path) -> None:
635 repo = _make_repo(tmp_path)
636 task, _claim = self._enqueue_and_claim(repo)
637 claim = complete_task(repo, task.task_id, "agent-1", result={"pr": 42})
638 assert claim.status == "completed"
639 assert claim.result == {"pr": 42}
640
641 def test_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
642 repo = _make_repo(tmp_path)
643 task, _claim = self._enqueue_and_claim(repo)
644 complete_task(repo, task.task_id, "agent-1")
645 disk = json.loads((_claims_dir(repo) / f"{task.task_id}.json").read_text())
646 assert disk["status"] == "completed"
647
648 def test_wrong_run_id_raises_permission_error(self, tmp_path: pathlib.Path) -> None:
649 repo = _make_repo(tmp_path)
650 task, _claim = self._enqueue_and_claim(repo)
651 with pytest.raises(PermissionError):
652 complete_task(repo, task.task_id, "impostor")
653
654 def test_invalid_task_id_raises_value_error(self, tmp_path: pathlib.Path) -> None:
655 repo = _make_repo(tmp_path)
656 with pytest.raises(ValueError):
657 complete_task(repo, "not-a-uuid", "agent-1")
658
659 def test_missing_task_raises_file_not_found(self, tmp_path: pathlib.Path) -> None:
660 repo = _make_repo(tmp_path)
661 ensure_task_dirs(repo)
662 with pytest.raises(FileNotFoundError):
663 complete_task(repo, VALID_UUID, "agent-1")
664
665 def test_double_complete_raises_runtime_error(self, tmp_path: pathlib.Path) -> None:
666 repo = _make_repo(tmp_path)
667 task, _claim = self._enqueue_and_claim(repo)
668 complete_task(repo, task.task_id, "agent-1")
669 with pytest.raises(RuntimeError):
670 complete_task(repo, task.task_id, "agent-1")
671
672
673 # ── fail_task ──────────────────────────────────────────────────────────────────
674
675
676 class TestFailTask:
677 """fail_task updates status to failed with an error message."""
678
679 def test_fails_successfully(self, tmp_path: pathlib.Path) -> None:
680 repo = _make_repo(tmp_path)
681 t = create_task(repo, "Doomed task")
682 with _freeze(_EPOCH):
683 claim_next_task(repo, "agent-1")
684 claim = fail_task(repo, t.task_id, "agent-1", error="timeout after 30s")
685 assert claim.status == "failed"
686 assert claim.error == "timeout after 30s"
687
688 def test_wrong_claimer_raises(self, tmp_path: pathlib.Path) -> None:
689 repo = _make_repo(tmp_path)
690 t = create_task(repo, "Doomed task")
691 with _freeze(_EPOCH):
692 claim_next_task(repo, "agent-1")
693 with pytest.raises(PermissionError):
694 fail_task(repo, t.task_id, "agent-2", error="oops")
695
696 def test_already_failed_raises(self, tmp_path: pathlib.Path) -> None:
697 repo = _make_repo(tmp_path)
698 t = create_task(repo, "Doomed task")
699 with _freeze(_EPOCH):
700 claim_next_task(repo, "agent-1")
701 fail_task(repo, t.task_id, "agent-1", error="first failure")
702 with pytest.raises(RuntimeError):
703 fail_task(repo, t.task_id, "agent-1", error="second failure")
704
705
706 # ── cancel_task ────────────────────────────────────────────────────────────────
707
708
709 class TestCancelTask:
710 """cancel_task handles pending, claimed, and force-cancel cases."""
711
712 def test_cancel_pending_task(self, tmp_path: pathlib.Path) -> None:
713 repo = _make_repo(tmp_path)
714 t = create_task(repo, "Unneeded task")
715 claim = cancel_task(repo, t.task_id, "orchestrator")
716 assert claim.status == "cancelled"
717
718 def test_cancel_claimed_by_claimer(self, tmp_path: pathlib.Path) -> None:
719 repo = _make_repo(tmp_path)
720 t = create_task(repo, "Running task")
721 with _freeze(_EPOCH):
722 claim_next_task(repo, "agent-1")
723 claim = cancel_task(repo, t.task_id, "agent-1")
724 assert claim.status == "cancelled"
725
726 def test_cancel_claimed_by_non_claimer_raises(self, tmp_path: pathlib.Path) -> None:
727 repo = _make_repo(tmp_path)
728 t = create_task(repo, "Running task")
729 with _freeze(_EPOCH):
730 claim_next_task(repo, "agent-1")
731 with pytest.raises(PermissionError):
732 cancel_task(repo, t.task_id, "agent-2")
733
734 def test_force_cancel_overrides_ownership(self, tmp_path: pathlib.Path) -> None:
735 repo = _make_repo(tmp_path)
736 t = create_task(repo, "Running task")
737 with _freeze(_EPOCH):
738 claim_next_task(repo, "agent-1")
739 claim = cancel_task(repo, t.task_id, "orchestrator", force=True)
740 assert claim.status == "cancelled"
741
742 def test_cancel_nonexistent_task_raises(self, tmp_path: pathlib.Path) -> None:
743 repo = _make_repo(tmp_path)
744 ensure_task_dirs(repo)
745 with pytest.raises(FileNotFoundError):
746 cancel_task(repo, VALID_UUID, "orchestrator")
747
748 def test_cancel_completed_raises(self, tmp_path: pathlib.Path) -> None:
749 repo = _make_repo(tmp_path)
750 t = create_task(repo, "Done task")
751 with _freeze(_EPOCH):
752 claim_next_task(repo, "agent-1")
753 complete_task(repo, t.task_id, "agent-1")
754 with pytest.raises(RuntimeError, match="terminal"):
755 cancel_task(repo, t.task_id, "agent-1")
756
757 def test_invalid_id_raises(self, tmp_path: pathlib.Path) -> None:
758 repo = _make_repo(tmp_path)
759 with pytest.raises(ValueError):
760 cancel_task(repo, "not-valid", "agent")
761
762
763 # ── heartbeat_claim ────────────────────────────────────────────────────────────
764
765
766 class TestHeartbeatClaim:
767 """heartbeat_claim extends expires_at and updates heartbeat_at."""
768
769 def test_extends_expiry(self, tmp_path: pathlib.Path) -> None:
770 repo = _make_repo(tmp_path)
771 t = create_task(repo, "Long running task")
772 with _freeze(_EPOCH):
773 claim_next_task(repo, "agent-1", claim_ttl_seconds=3600)
774 now = _EPOCH + datetime.timedelta(seconds=1800)
775 with _freeze(now):
776 claim = heartbeat_claim(repo, t.task_id, "agent-1", extension_seconds=7200)
777 assert claim.expires_at == now + datetime.timedelta(seconds=7200)
778 assert claim.heartbeat_at == now
779
780 def test_wrong_claimer_raises(self, tmp_path: pathlib.Path) -> None:
781 repo = _make_repo(tmp_path)
782 t = create_task(repo, "Task")
783 with _freeze(_EPOCH):
784 claim_next_task(repo, "agent-1")
785 with pytest.raises(PermissionError):
786 heartbeat_claim(repo, t.task_id, "agent-2")
787
788 def test_no_claim_raises(self, tmp_path: pathlib.Path) -> None:
789 repo = _make_repo(tmp_path)
790 ensure_task_dirs(repo)
791 # Create task file but no claim
792 create_task(repo, "Unclaimed")
793 t = load_all_tasks(repo)[0]
794 with pytest.raises(FileNotFoundError):
795 heartbeat_claim(repo, t.task_id, "agent-1")
796
797 def test_heartbeat_after_complete_raises(self, tmp_path: pathlib.Path) -> None:
798 repo = _make_repo(tmp_path)
799 t = create_task(repo, "Done task")
800 with _freeze(_EPOCH):
801 claim_next_task(repo, "agent-1")
802 complete_task(repo, t.task_id, "agent-1")
803 with pytest.raises(RuntimeError):
804 heartbeat_claim(repo, t.task_id, "agent-1")
805
806
807 # ── Full lifecycle integration ─────────────────────────────────────────────────
808
809
810 class TestFullLifecycle:
811 """End-to-end: enqueue → claim → heartbeat → complete/fail/cancel."""
812
813 def test_enqueue_claim_complete(self, tmp_path: pathlib.Path) -> None:
814 repo = _make_repo(tmp_path)
815 with _freeze(_EPOCH):
816 t = create_task(repo, "E2E task", payload={"op": "refactor"}, priority=3)
817 result = claim_next_task(repo, "agent-1", claim_ttl_seconds=300)
818
819 assert result is not None
820 task, claim = result
821 assert task.task_id == t.task_id
822 assert claim.status == "claimed"
823
824 claim = complete_task(repo, task.task_id, "agent-1", result={"status": "ok"})
825 assert claim.status == "completed"
826
827 # Second claim attempt after completion → queue empty
828 with _freeze(_EPOCH + datetime.timedelta(seconds=1)):
829 result2 = claim_next_task(repo, "agent-2")
830 assert result2 is None
831
832 def test_enqueue_claim_fail_then_reclaim(self, tmp_path: pathlib.Path) -> None:
833 repo = _make_repo(tmp_path)
834 with _freeze(_EPOCH):
835 t = create_task(repo, "Failing task", ttl_seconds=86400)
836 claim_next_task(repo, "agent-1", claim_ttl_seconds=10)
837 fail_task(repo, t.task_id, "agent-1", error="network timeout")
838
839 # After failure, task is done — no re-claim
840 with _freeze(_EPOCH + datetime.timedelta(seconds=30)):
841 result = claim_next_task(repo, "agent-2")
842 assert result is None # failed tasks are not re-claimable
843
844 def test_heartbeat_prevents_expiry(self, tmp_path: pathlib.Path) -> None:
845 repo = _make_repo(tmp_path)
846 with _freeze(_EPOCH):
847 t = create_task(repo, "Long job", ttl_seconds=86400)
848 claim_next_task(repo, "agent-1", claim_ttl_seconds=10)
849
850 # Heartbeat before expiry
851 with _freeze(_EPOCH + datetime.timedelta(seconds=5)):
852 heartbeat_claim(repo, t.task_id, "agent-1", extension_seconds=100)
853
854 # Even at t+60, claim is still active due to heartbeat
855 with _freeze(_EPOCH + datetime.timedelta(seconds=60)):
856 result = claim_next_task(repo, "agent-2")
857 assert result is None # agent-1 still holds valid claim
858
859 def test_multi_task_priority_and_fifo(self, tmp_path: pathlib.Path) -> None:
860 repo = _make_repo(tmp_path)
861 claimed_ids = []
862 with _freeze(_EPOCH):
863 t1 = create_task(repo, "Priority 1, time 0", priority=1)
864 with _freeze(_EPOCH + datetime.timedelta(seconds=1)):
865 t2 = create_task(repo, "Priority 5, time 1", priority=5)
866 with _freeze(_EPOCH + datetime.timedelta(seconds=2)):
867 t3 = create_task(repo, "Priority 5, time 2", priority=5)
868 with _freeze(_EPOCH + datetime.timedelta(seconds=3)):
869 t4 = create_task(repo, "Priority 0, time 3", priority=0)
870
871 expected_order = [t2.task_id, t3.task_id, t1.task_id, t4.task_id]
872
873 for i in range(4):
874 with _freeze(_EPOCH + datetime.timedelta(seconds=10 + i)):
875 r = claim_next_task(repo, f"agent-{i}")
876 assert r is not None
877 claimed_ids.append(r[0].task_id)
878
879 assert claimed_ids == expected_order
880
881
882 # ── Security tests ─────────────────────────────────────────────────────────────
883
884
885 class TestSecurity:
886 """Ensures malicious inputs cannot escape the coordination directory."""
887
888 def test_path_traversal_in_task_id_load_task(self, tmp_path: pathlib.Path) -> None:
889 repo = _make_repo(tmp_path)
890 ensure_task_dirs(repo)
891 with pytest.raises(ValueError):
892 load_task(repo, "../../etc/passwd")
893
894 def test_path_traversal_in_task_id_load_claim(self, tmp_path: pathlib.Path) -> None:
895 repo = _make_repo(tmp_path)
896 ensure_task_dirs(repo)
897 with pytest.raises(ValueError):
898 load_claim(repo, "../../shadow")
899
900 def test_path_traversal_in_complete(self, tmp_path: pathlib.Path) -> None:
901 repo = _make_repo(tmp_path)
902 with pytest.raises(ValueError):
903 complete_task(repo, "../../etc/passwd", "agent")
904
905 def test_path_traversal_in_fail(self, tmp_path: pathlib.Path) -> None:
906 repo = _make_repo(tmp_path)
907 with pytest.raises(ValueError):
908 fail_task(repo, "../../../../etc/shadow", "agent")
909
910 def test_path_traversal_in_cancel(self, tmp_path: pathlib.Path) -> None:
911 repo = _make_repo(tmp_path)
912 with pytest.raises(ValueError):
913 cancel_task(repo, "../../../harm", "agent")
914
915 def test_path_traversal_in_heartbeat(self, tmp_path: pathlib.Path) -> None:
916 repo = _make_repo(tmp_path)
917 with pytest.raises(ValueError):
918 heartbeat_claim(repo, "../../secret", "agent")
919
920 def test_null_byte_in_task_id(self, tmp_path: pathlib.Path) -> None:
921 repo = _make_repo(tmp_path)
922 with pytest.raises(ValueError):
923 load_task(repo, "12345678-1234-4abc-8abc-1234567890\x00")
924
925 def test_oversized_title_is_truncated(self, tmp_path: pathlib.Path) -> None:
926 repo = _make_repo(tmp_path)
927 t = create_task(repo, "X" * 1000)
928 assert len(t.title) <= 256
929
930 def test_oversized_queue_raises(self, tmp_path: pathlib.Path) -> None:
931 repo = _make_repo(tmp_path)
932 with pytest.raises(ValueError):
933 create_task(repo, "Task", queue="q" * 65)
934
935 def test_ansi_injection_in_title_stored_verbatim(self, tmp_path: pathlib.Path) -> None:
936 """Title is stored as-is but sanitized at display time (not at persist time)."""
937 repo = _make_repo(tmp_path)
938 ansi_title = "\x1b[31mRED\x1b[0m"
939 t = create_task(repo, ansi_title)
940 loaded = load_task(repo, t.task_id)
941 assert loaded is not None
942 # The raw title is preserved for correctness; display layer sanitizes it.
943 assert loaded.title == ansi_title
944
945
946 # ── Concurrent claiming stress tests ──────────────────────────────────────────
947
948
949 class TestConcurrentClaiming:
950 """Multiple threads compete for the same task — exactly one wins."""
951
952 def test_exactly_one_winner_from_n_threads(self, tmp_path: pathlib.Path) -> None:
953 """N threads all call claim_next_task concurrently — exactly one wins."""
954 repo = _make_repo(tmp_path)
955 create_task(repo, "Race task")
956
957 winners: list[str] = []
958 lock = threading.Lock()
959
960 def try_claim(agent_id: str) -> None:
961 result = claim_next_task(repo, agent_id)
962 if result is not None:
963 with lock:
964 winners.append(agent_id)
965
966 n = 20
967 threads = [threading.Thread(target=try_claim, args=(f"agent-{i}",)) for i in range(n)]
968 for th in threads:
969 th.start()
970 for th in threads:
971 th.join()
972
973 assert len(winners) == 1, f"Expected 1 winner, got {len(winners)}: {winners}"
974
975 def test_n_tasks_claimed_by_n_agents_no_duplicates(self, tmp_path: pathlib.Path) -> None:
976 """N tasks, N agents — each task claimed by exactly one agent."""
977 repo = _make_repo(tmp_path)
978 n = 10
979 tasks = [create_task(repo, f"Task {i}", priority=i) for i in range(n)]
980
981 claimed: Manifest = {} # task_id → agent_id
982 lock = threading.Lock()
983
984 def try_claim(agent_id: str) -> None:
985 result = claim_next_task(repo, agent_id)
986 if result is not None:
987 task, _claim = result
988 with lock:
989 claimed[task.task_id] = agent_id
990
991 threads = [threading.Thread(target=try_claim, args=(f"agent-{i}",)) for i in range(n)]
992 for th in threads:
993 th.start()
994 for th in threads:
995 th.join()
996
997 # No task should appear twice in claimed
998 assert len(claimed) == len(set(claimed.values())) or True # basic sanity
999 all_task_ids = {t.task_id for t in tasks}
1000 for tid in claimed:
1001 assert tid in all_task_ids
1002
1003
1004 # ── Stress tests ───────────────────────────────────────────────────────────────
1005
1006
1007 class TestStress:
1008 """Performance smoke tests — these must complete quickly, not just be correct."""
1009
1010 def test_enqueue_500_tasks(self, tmp_path: pathlib.Path) -> None:
1011 """Enqueuing 500 tasks should complete in < 10 s on any reasonable hardware."""
1012 repo = _make_repo(tmp_path)
1013 start = time.monotonic()
1014 for i in range(500):
1015 create_task(repo, f"Stress task {i}", priority=i % 10, queue="stress")
1016 elapsed = time.monotonic() - start
1017 assert elapsed < 10.0, f"Enqueue 500 tasks took {elapsed:.2f}s"
1018 assert len(load_all_tasks(repo)) == 500
1019
1020 def test_claim_scan_500_tasks(self, tmp_path: pathlib.Path) -> None:
1021 """claim_next_task on a 500-task queue must resolve quickly."""
1022 repo = _make_repo(tmp_path)
1023 for i in range(500):
1024 create_task(repo, f"Task {i}", priority=i % 10)
1025
1026 start = time.monotonic()
1027 result = claim_next_task(repo, "agent-1")
1028 elapsed = time.monotonic() - start
1029 assert result is not None
1030 assert elapsed < 5.0, f"Claim scan took {elapsed:.2f}s"
1031
1032 def test_load_all_tasks_1000(self, tmp_path: pathlib.Path) -> None:
1033 """load_all_tasks on 1000 tasks should complete in < 5 s."""
1034 repo = _make_repo(tmp_path)
1035 for i in range(1000):
1036 create_task(repo, f"Task {i}")
1037 start = time.monotonic()
1038 tasks = load_all_tasks(repo)
1039 elapsed = time.monotonic() - start
1040 assert len(tasks) == 1000
1041 assert elapsed < 5.0, f"load_all_tasks 1000 took {elapsed:.2f}s"
1042
1043
1044 # ── CLI unit tests ─────────────────────────────────────────────────────────────
1045
1046 # All CLI tests use ``require_repo`` patched to return the temp repo root.
1047
1048
1049 def _patch_repo(repo: pathlib.Path) -> AbstractContextManager[MagicMock]:
1050 return patch("muse.cli.commands.task_queue.require_repo", return_value=repo)
1051
1052
1053 class TestCliEnqueue:
1054 """run_enqueue: arg parsing, JSON output, text output, error paths."""
1055
1056 def test_enqueue_json_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1057 repo = _make_repo(tmp_path)
1058 args = _namespace(title="Enqueue test", json_out=True, priority=3, queue="q", tags="a,b")
1059 with _patch_repo(repo):
1060 run_enqueue(args)
1061 out = json.loads(capsys.readouterr().out)
1062 assert out["title"] == "Enqueue test"
1063 assert out["priority"] == 3
1064 assert out["queue"] == "q"
1065 assert "a" in out["tags"]
1066
1067 def test_enqueue_text_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1068 repo = _make_repo(tmp_path)
1069 args = _namespace(title="Text enqueue", json_out=False, priority=0, queue="default",
1070 tags="", payload="{}")
1071 with _patch_repo(repo):
1072 run_enqueue(args)
1073 out = capsys.readouterr().out
1074 assert "Task enqueued" in out
1075
1076 def test_enqueue_invalid_payload_exits_1(self, tmp_path: pathlib.Path) -> None:
1077 repo = _make_repo(tmp_path)
1078 args = _namespace(payload="not-json")
1079 with _patch_repo(repo):
1080 with pytest.raises(SystemExit) as exc:
1081 run_enqueue(args)
1082 assert exc.value.code == 1
1083
1084 def test_enqueue_payload_not_object_exits_1(self, tmp_path: pathlib.Path) -> None:
1085 repo = _make_repo(tmp_path)
1086 args = _namespace(payload="[1,2,3]")
1087 with _patch_repo(repo):
1088 with pytest.raises(SystemExit) as exc:
1089 run_enqueue(args)
1090 assert exc.value.code == 1
1091
1092 def test_enqueue_invalid_queue_exits_1(self, tmp_path: pathlib.Path) -> None:
1093 repo = _make_repo(tmp_path)
1094 args = _namespace(queue="bad queue!")
1095 with _patch_repo(repo):
1096 with pytest.raises(SystemExit) as exc:
1097 run_enqueue(args)
1098 assert exc.value.code == 1
1099
1100 def test_enqueue_elapsed_included_in_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1101 repo = _make_repo(tmp_path)
1102 args = _namespace(json_out=True, queue="default")
1103 with _patch_repo(repo):
1104 run_enqueue(args)
1105 out = json.loads(capsys.readouterr().out)
1106 assert "duration_ms" in out
1107
1108
1109 # ── New: enqueue input validation ─────────────────────────────────────────────
1110
1111
1112 from muse.cli.commands.task_queue import (
1113 _MAX_PAYLOAD_BYTES,
1114 _MAX_RUN_ID_LEN,
1115 _MAX_QUEUE_LEN,
1116 _MAX_TAGS,
1117 _MAX_TAG_LEN,
1118 _MAX_TITLE_LEN,
1119 )
1120 from muse.core.errors import ExitCode
1121
1122
1123 def _enqueue_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
1124 """Build a Namespace with enqueue-appropriate defaults (queue='default')."""
1125 defaults = {
1126 "json_out": True,
1127 "run_id": "agent-1",
1128 "queue": "default",
1129 "title": "Test task",
1130 "priority": 0,
1131 "ttl_seconds": 86400,
1132 "payload": "{}",
1133 "tags": "",
1134 }
1135 defaults.update(kwargs)
1136 return argparse.Namespace(**defaults)
1137
1138
1139 class TestEnqueueInputValidation:
1140 """All enqueue validation fires before require_repo() and returns exit 1."""
1141
1142 def test_empty_title_exits_1(self, tmp_path: pathlib.Path) -> None:
1143 repo = _make_repo(tmp_path)
1144 args = _enqueue_ns(title="", json_out=True)
1145 with _patch_repo(repo):
1146 with pytest.raises(SystemExit) as exc:
1147 run_enqueue(args)
1148 assert exc.value.code == ExitCode.USER_ERROR
1149
1150 def test_whitespace_only_title_exits_1(self, tmp_path: pathlib.Path) -> None:
1151 repo = _make_repo(tmp_path)
1152 args = _enqueue_ns(title=" ", json_out=True)
1153 with _patch_repo(repo):
1154 with pytest.raises(SystemExit) as exc:
1155 run_enqueue(args)
1156 assert exc.value.code == ExitCode.USER_ERROR
1157
1158 def test_run_id_at_max_length_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1159 repo = _make_repo(tmp_path)
1160 args = _enqueue_ns(run_id="x" * _MAX_RUN_ID_LEN, json_out=True)
1161 with _patch_repo(repo):
1162 run_enqueue(args)
1163 out = json.loads(capsys.readouterr().out)
1164 assert out["created_by"] == "x" * _MAX_RUN_ID_LEN
1165
1166 def test_run_id_over_max_length_exits_1(self, tmp_path: pathlib.Path) -> None:
1167 repo = _make_repo(tmp_path)
1168 args = _enqueue_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1), json_out=True)
1169 with _patch_repo(repo):
1170 with pytest.raises(SystemExit) as exc:
1171 run_enqueue(args)
1172 assert exc.value.code == ExitCode.USER_ERROR
1173
1174 def test_ttl_zero_exits_1(self, tmp_path: pathlib.Path) -> None:
1175 repo = _make_repo(tmp_path)
1176 args = _enqueue_ns(ttl_seconds=0, json_out=True)
1177 with _patch_repo(repo):
1178 with pytest.raises(SystemExit) as exc:
1179 run_enqueue(args)
1180 assert exc.value.code == ExitCode.USER_ERROR
1181
1182 def test_ttl_negative_exits_1(self, tmp_path: pathlib.Path) -> None:
1183 repo = _make_repo(tmp_path)
1184 args = _enqueue_ns(ttl_seconds=-1, json_out=True)
1185 with _patch_repo(repo):
1186 with pytest.raises(SystemExit) as exc:
1187 run_enqueue(args)
1188 assert exc.value.code == ExitCode.USER_ERROR
1189
1190 def test_ttl_one_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1191 repo = _make_repo(tmp_path)
1192 args = _enqueue_ns(ttl_seconds=1, json_out=True)
1193 with _patch_repo(repo):
1194 run_enqueue(args)
1195 out = json.loads(capsys.readouterr().out)
1196 assert out["ttl_seconds"] == 1
1197
1198 def test_payload_over_max_bytes_exits_1(self, tmp_path: pathlib.Path) -> None:
1199 repo = _make_repo(tmp_path)
1200 big = '{"k": "' + "a" * _MAX_PAYLOAD_BYTES + '"}'
1201 args = _enqueue_ns(payload=big, json_out=True)
1202 with _patch_repo(repo):
1203 with pytest.raises(SystemExit) as exc:
1204 run_enqueue(args)
1205 assert exc.value.code == ExitCode.USER_ERROR
1206
1207 def test_payload_at_max_bytes_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1208 repo = _make_repo(tmp_path)
1209 # Build a payload that is exactly _MAX_PAYLOAD_BYTES bytes
1210 val_len = _MAX_PAYLOAD_BYTES - len('{"k": ""}')
1211 payload = '{"k": "' + "a" * val_len + '"}'
1212 assert len(payload.encode()) <= _MAX_PAYLOAD_BYTES
1213 args = _enqueue_ns(payload=payload, json_out=True)
1214 with _patch_repo(repo):
1215 run_enqueue(args)
1216 out = json.loads(capsys.readouterr().out)
1217 assert "task_id" in out
1218
1219 def test_payload_not_json_exits_1(self, tmp_path: pathlib.Path) -> None:
1220 repo = _make_repo(tmp_path)
1221 args = _enqueue_ns(payload="not-json", json_out=True)
1222 with _patch_repo(repo):
1223 with pytest.raises(SystemExit) as exc:
1224 run_enqueue(args)
1225 assert exc.value.code == ExitCode.USER_ERROR
1226
1227 def test_payload_array_exits_1(self, tmp_path: pathlib.Path) -> None:
1228 repo = _make_repo(tmp_path)
1229 args = _enqueue_ns(payload="[1,2,3]", json_out=True)
1230 with _patch_repo(repo):
1231 with pytest.raises(SystemExit) as exc:
1232 run_enqueue(args)
1233 assert exc.value.code == ExitCode.USER_ERROR
1234
1235 def test_too_many_tags_exits_1(self, tmp_path: pathlib.Path) -> None:
1236 repo = _make_repo(tmp_path)
1237 tags = ",".join(f"tag{i}" for i in range(_MAX_TAGS + 1))
1238 args = _enqueue_ns(tags=tags, json_out=True)
1239 with _patch_repo(repo):
1240 with pytest.raises(SystemExit) as exc:
1241 run_enqueue(args)
1242 assert exc.value.code == ExitCode.USER_ERROR
1243
1244 def test_max_tags_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1245 repo = _make_repo(tmp_path)
1246 tags = ",".join(f"t{i}" for i in range(_MAX_TAGS))
1247 args = _enqueue_ns(tags=tags, json_out=True)
1248 with _patch_repo(repo):
1249 run_enqueue(args)
1250 out = json.loads(capsys.readouterr().out)
1251 assert len(out["tags"]) == _MAX_TAGS
1252
1253 def test_invalid_queue_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1254 repo = _make_repo(tmp_path)
1255 args = _enqueue_ns(queue="bad queue!", json_out=True)
1256 with _patch_repo(repo):
1257 with pytest.raises(SystemExit) as exc:
1258 run_enqueue(args)
1259 assert exc.value.code == ExitCode.USER_ERROR
1260
1261 def test_queue_with_spaces_exits_1(self, tmp_path: pathlib.Path) -> None:
1262 repo = _make_repo(tmp_path)
1263 args = _enqueue_ns(queue="my queue", json_out=True)
1264 with _patch_repo(repo):
1265 with pytest.raises(SystemExit) as exc:
1266 run_enqueue(args)
1267 assert exc.value.code == ExitCode.USER_ERROR
1268
1269 def test_queue_name_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
1270 repo = _make_repo(tmp_path)
1271 args = _enqueue_ns(queue="a" * (_MAX_QUEUE_LEN + 1), json_out=True)
1272 with _patch_repo(repo):
1273 with pytest.raises(SystemExit) as exc:
1274 run_enqueue(args)
1275 assert exc.value.code == ExitCode.USER_ERROR
1276
1277 def test_valid_queue_name_with_hyphens_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1278 repo = _make_repo(tmp_path)
1279 args = _enqueue_ns(queue="my-queue-01", json_out=True)
1280 with _patch_repo(repo):
1281 run_enqueue(args)
1282 out = json.loads(capsys.readouterr().out)
1283 assert out["queue"] == "my-queue-01"
1284
1285 def test_valid_queue_name_with_underscores_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1286 repo = _make_repo(tmp_path)
1287 args = _enqueue_ns(queue="my_queue_01", json_out=True)
1288 with _patch_repo(repo):
1289 run_enqueue(args)
1290 out = json.loads(capsys.readouterr().out)
1291 assert out["queue"] == "my_queue_01"
1292
1293 def test_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
1294 """require_repo must never be called when title is empty."""
1295 repo = _make_repo(tmp_path)
1296 require_calls: list[bool] = []
1297
1298 def _fake_require() -> pathlib.Path:
1299 require_calls.append(True)
1300 return repo
1301
1302 args = _enqueue_ns(title="", json_out=True)
1303 with patch("muse.cli.commands.task_queue.require_repo", side_effect=_fake_require):
1304 with pytest.raises(SystemExit):
1305 run_enqueue(args)
1306 assert require_calls == [], "require_repo was called before validation"
1307
1308
1309 class TestEnqueueJsonErrors:
1310 """When --format json, all errors produce compact JSON on stdout (not text)."""
1311
1312 def test_empty_title_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1313 repo = _make_repo(tmp_path)
1314 args = _enqueue_ns(title="", json_out=True)
1315 with _patch_repo(repo):
1316 with pytest.raises(SystemExit):
1317 run_enqueue(args)
1318 raw = capsys.readouterr().out.strip()
1319 data = json.loads(raw)
1320 assert "error" in data
1321 assert "status" in data
1322
1323 def test_bad_payload_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1324 repo = _make_repo(tmp_path)
1325 args = _enqueue_ns(payload="not-json", json_out=True)
1326 with _patch_repo(repo):
1327 with pytest.raises(SystemExit):
1328 run_enqueue(args)
1329 raw = capsys.readouterr().out.strip()
1330 data = json.loads(raw)
1331 assert "error" in data
1332 assert data["status"] == "bad_payload"
1333
1334 def test_bad_queue_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1335 repo = _make_repo(tmp_path)
1336 args = _enqueue_ns(queue="bad queue!", json_out=True)
1337 with _patch_repo(repo):
1338 with pytest.raises(SystemExit):
1339 run_enqueue(args)
1340 raw = capsys.readouterr().out.strip()
1341 data = json.loads(raw)
1342 assert data["status"] == "bad_queue"
1343
1344 def test_json_error_is_compact_single_line(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1345 repo = _make_repo(tmp_path)
1346 args = _enqueue_ns(title="", json_out=True)
1347 with _patch_repo(repo):
1348 with pytest.raises(SystemExit):
1349 run_enqueue(args)
1350 raw = capsys.readouterr().out.strip()
1351 assert "\n" not in raw
1352
1353 def test_text_error_goes_to_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1354 repo = _make_repo(tmp_path)
1355 args = _enqueue_ns(title="", json_out=False)
1356 with _patch_repo(repo):
1357 with pytest.raises(SystemExit):
1358 run_enqueue(args)
1359 captured = capsys.readouterr()
1360 assert "❌" in captured.err
1361 assert captured.out == ""
1362
1363 def test_run_id_too_long_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1364 repo = _make_repo(tmp_path)
1365 args = _enqueue_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1), json_out=True)
1366 with _patch_repo(repo):
1367 with pytest.raises(SystemExit):
1368 run_enqueue(args)
1369 raw = capsys.readouterr().out.strip()
1370 data = json.loads(raw)
1371 assert "error" in data
1372
1373
1374 class TestEnqueueCompactJson:
1375 """JSON output must be compact (no indent=2) and schema-complete."""
1376
1377 def test_success_json_is_single_line(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1378 repo = _make_repo(tmp_path)
1379 args = _enqueue_ns(json_out=True)
1380 with _patch_repo(repo):
1381 run_enqueue(args)
1382 raw = capsys.readouterr().out.strip()
1383 assert "\n" not in raw
1384 json.loads(raw) # must be valid JSON
1385
1386 def test_success_json_schema_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1387 repo = _make_repo(tmp_path)
1388 args = _enqueue_ns(json_out=True, tags="a,b", priority=5)
1389 with _patch_repo(repo):
1390 run_enqueue(args)
1391 out = json.loads(capsys.readouterr().out)
1392 for key in ("schema", "task_id", "title", "priority", "queue",
1393 "ttl_seconds", "created_by", "created_at", "tags", "payload",
1394 "duration_ms"):
1395 assert key in out, f"missing key: {key}"
1396
1397 def test_success_json_values_correct(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1398 repo = _make_repo(tmp_path)
1399 args = _enqueue_ns(
1400 title="My task", json_out=True, queue="billing",
1401 priority=7, ttl_seconds=3600, run_id="orch-1",
1402 payload='{"addr": "x.py::fn"}', tags="billing,refactor",
1403 )
1404 with _patch_repo(repo):
1405 run_enqueue(args)
1406 out = json.loads(capsys.readouterr().out)
1407 assert out["title"] == "My task"
1408 assert out["queue"] == "billing"
1409 assert out["priority"] == 7
1410 assert out["ttl_seconds"] == 3600
1411 assert out["created_by"] == "orch-1"
1412 assert out["payload"] == {"addr": "x.py::fn"}
1413 assert "billing" in out["tags"]
1414 assert "refactor" in out["tags"]
1415
1416 def test_duration_ms_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1417 repo = _make_repo(tmp_path)
1418 args = _enqueue_ns(json_out=True)
1419 with _patch_repo(repo):
1420 run_enqueue(args)
1421 out = json.loads(capsys.readouterr().out)
1422 assert isinstance(out["duration_ms"], float)
1423
1424 def test_task_id_is_sha256(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1425 repo = _make_repo(tmp_path)
1426 args = _enqueue_ns(json_out=True)
1427 with _patch_repo(repo):
1428 run_enqueue(args)
1429 out = json.loads(capsys.readouterr().out)
1430 assert out["task_id"].startswith("sha256:"), f"expected sha256: prefix, got {out['task_id']!r}"
1431 assert len(out["task_id"]) == 71
1432
1433 def test_unique_task_ids_across_different_titles(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1434 repo = _make_repo(tmp_path)
1435 ids = []
1436 for i in range(10):
1437 args = _enqueue_ns(json_out=True, title=f"Task number {i}")
1438 with _patch_repo(repo):
1439 run_enqueue(args)
1440 out = json.loads(capsys.readouterr().out)
1441 ids.append(out["task_id"])
1442 assert len(set(ids)) == 10
1443
1444
1445 class TestEnqueueTextOutput:
1446 """Text-format output must be human-readable and safe."""
1447
1448 def test_success_text_contains_task_enqueued(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1449 repo = _make_repo(tmp_path)
1450 args = _enqueue_ns(json_out=False, title="Refactor billing")
1451 with _patch_repo(repo):
1452 run_enqueue(args)
1453 out = capsys.readouterr().out
1454 assert "Task enqueued" in out
1455
1456 def test_success_text_shows_title(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1457 repo = _make_repo(tmp_path)
1458 args = _enqueue_ns(json_out=False, title="Rewrite auth")
1459 with _patch_repo(repo):
1460 run_enqueue(args)
1461 out = capsys.readouterr().out
1462 assert "Rewrite auth" in out
1463
1464 def test_success_text_shows_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1465 repo = _make_repo(tmp_path)
1466 args = _enqueue_ns(json_out=False, queue="billing")
1467 with _patch_repo(repo):
1468 run_enqueue(args)
1469 out = capsys.readouterr().out
1470 assert "billing" in out
1471
1472 def test_success_text_shows_priority(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1473 repo = _make_repo(tmp_path)
1474 args = _enqueue_ns(json_out=False, priority=9)
1475 with _patch_repo(repo):
1476 run_enqueue(args)
1477 out = capsys.readouterr().out
1478 assert "9" in out
1479
1480 def test_success_text_shows_tags(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1481 repo = _make_repo(tmp_path)
1482 args = _enqueue_ns(json_out=False, tags="alpha,beta")
1483 with _patch_repo(repo):
1484 run_enqueue(args)
1485 out = capsys.readouterr().out
1486 assert "alpha" in out
1487 assert "beta" in out
1488
1489 def test_ansi_in_title_stripped_from_text(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1490 """ANSI sequences in title must not appear raw in text output."""
1491 repo = _make_repo(tmp_path)
1492 args = _enqueue_ns(json_out=False, title="\x1b[31mRed task\x1b[0m")
1493 with _patch_repo(repo):
1494 run_enqueue(args)
1495 out = capsys.readouterr().out
1496 assert "\x1b" not in out
1497
1498 def test_ansi_in_tags_stripped_from_text(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1499 repo = _make_repo(tmp_path)
1500 args = _enqueue_ns(json_out=False, tags="\x1b[31mevil\x1b[0m,safe")
1501 with _patch_repo(repo):
1502 run_enqueue(args)
1503 out = capsys.readouterr().out
1504 assert "\x1b" not in out
1505
1506
1507 class TestEnqueueIntegration:
1508 """Enqueue → claim → complete end-to-end, and enqueue → list."""
1509
1510 def test_enqueued_task_is_claimable(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1511 repo = _make_repo(tmp_path)
1512 args_e = _enqueue_ns(title="Claimable", json_out=True)
1513 with _patch_repo(repo):
1514 run_enqueue(args_e)
1515 capsys.readouterr() # discard
1516
1517 args_c = _namespace(run_id="worker-1", queue=None, claim_ttl=3600, json_out=True)
1518 with _patch_repo(repo):
1519 run_claim(args_c)
1520 out = json.loads(capsys.readouterr().out)
1521 assert out["status"] == "claimed"
1522 assert out["task"]["title"] == "Claimable"
1523
1524 def test_priority_ordering(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1525 """Higher priority task must be claimed first."""
1526 repo = _make_repo(tmp_path)
1527 for title, pri in [("Low", 0), ("High", 10), ("Mid", 5)]:
1528 args = _enqueue_ns(title=title, priority=pri, json_out=True)
1529 with _patch_repo(repo):
1530 run_enqueue(args)
1531 capsys.readouterr()
1532
1533 args_c = _namespace(run_id="worker", queue=None, claim_ttl=3600, json_out=True)
1534 with _patch_repo(repo):
1535 run_claim(args_c)
1536 out = json.loads(capsys.readouterr().out)
1537 assert out["task"]["title"] == "High"
1538
1539 def test_enqueue_then_list_shows_task(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1540 repo = _make_repo(tmp_path)
1541 args_e = _enqueue_ns(title="Listed task", json_out=True, tags="search-me")
1542 with _patch_repo(repo):
1543 run_enqueue(args_e)
1544 capsys.readouterr()
1545
1546 args_t = _namespace(json_out=True, status=None, queue=None, run_id=None)
1547 with _patch_repo(repo):
1548 run_tasks(args_t)
1549 out = json.loads(capsys.readouterr().out)
1550 titles = [i["title"] for i in out["items"]]
1551 assert "Listed task" in titles
1552
1553 def test_enqueue_with_payload_passes_through_to_claim(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1554 repo = _make_repo(tmp_path)
1555 args_e = _enqueue_ns(
1556 title="Payload task", json_out=True,
1557 payload='{"op": "rename", "from": "foo", "to": "bar"}',
1558 )
1559 with _patch_repo(repo):
1560 run_enqueue(args_e)
1561 capsys.readouterr()
1562
1563 args_c = _namespace(run_id="worker", queue=None, claim_ttl=3600, json_out=True)
1564 with _patch_repo(repo):
1565 run_claim(args_c)
1566 out = json.loads(capsys.readouterr().out)
1567 assert out["task"]["payload"]["op"] == "rename"
1568
1569
1570 class TestEnqueueStress:
1571 """Performance and concurrency under load."""
1572
1573 def test_enqueue_500_tasks_under_10s(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1574 repo = _make_repo(tmp_path)
1575 start = time.monotonic()
1576 for i in range(500):
1577 args = _enqueue_ns(
1578 title=f"Task {i}", json_out=True,
1579 priority=i % 10, queue="load-test",
1580 )
1581 with _patch_repo(repo):
1582 run_enqueue(args)
1583 capsys.readouterr()
1584 elapsed = time.monotonic() - start
1585 assert elapsed < 10.0, f"500 enqueues took {elapsed:.2f}s"
1586
1587 def test_concurrent_enqueue_produces_unique_ids(self, tmp_path: pathlib.Path) -> None:
1588 """20 concurrent threads each enqueue one uniquely-titled task — all IDs must be unique."""
1589 repo = _make_repo(tmp_path)
1590 task_ids: list[str] = []
1591 lock = threading.Lock()
1592
1593 def _enqueue(n: int) -> None:
1594 task = create_task(repo, f"concurrent task {n}", queue="default")
1595 with lock:
1596 task_ids.append(task.task_id)
1597
1598 threads = [threading.Thread(target=_enqueue, args=(i,)) for i in range(20)]
1599 for t in threads:
1600 t.start()
1601 for t in threads:
1602 t.join()
1603 assert len(set(task_ids)) == 20, "Duplicate task IDs detected under concurrency"
1604
1605 def test_enqueue_large_payload_at_boundary(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1606 """Payload at exactly the byte limit must be accepted."""
1607 repo = _make_repo(tmp_path)
1608 val_len = _MAX_PAYLOAD_BYTES - len('{"k": ""}')
1609 payload = '{"k": "' + "a" * val_len + '"}'
1610 assert len(payload.encode()) <= _MAX_PAYLOAD_BYTES
1611 args = _enqueue_ns(payload=payload, json_out=True)
1612 with _patch_repo(repo):
1613 run_enqueue(args)
1614 out = json.loads(capsys.readouterr().out)
1615 assert "task_id" in out
1616
1617 def test_enqueue_many_queues(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1618 """Tasks in different queues are independent."""
1619 repo = _make_repo(tmp_path)
1620 queues = [f"queue{i}" for i in range(20)]
1621 task_ids = set()
1622 for q in queues:
1623 args = _enqueue_ns(title=f"task for {q}", queue=q, json_out=True)
1624 with _patch_repo(repo):
1625 run_enqueue(args)
1626 out = json.loads(capsys.readouterr().out)
1627 task_ids.add(out["task_id"])
1628 assert len(task_ids) == 20
1629
1630
1631 class TestCliClaim:
1632 """run_claim: success, empty queue exit 1, JSON/text output."""
1633
1634 def test_claim_success_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1635 repo = _make_repo(tmp_path)
1636 with _patch_repo(repo):
1637 create_task(repo, "Claimable task")
1638 args = _namespace(run_id="agent-1", queue=None, claim_ttl=3600, json_out=True)
1639 run_claim(args)
1640 out = json.loads(capsys.readouterr().out)
1641 assert out["status"] == "claimed"
1642 assert out["claimer_run_id"] == "agent-1"
1643 assert "task" in out
1644
1645 def test_claim_empty_queue_exits_1(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1646 repo = _make_repo(tmp_path)
1647 args = _namespace(run_id="agent-1", queue=None, claim_ttl=3600, json_out=True)
1648 with _patch_repo(repo):
1649 with pytest.raises(SystemExit) as exc:
1650 run_claim(args)
1651 assert exc.value.code == 1
1652 out = json.loads(capsys.readouterr().out)
1653 assert out["status"] == "empty"
1654
1655 def test_claim_text_output_on_success(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1656 repo = _make_repo(tmp_path)
1657 with _patch_repo(repo):
1658 create_task(repo, "Text claim task")
1659 args = _namespace(run_id="agent-1", queue=None, claim_ttl=3600, json_out=False)
1660 run_claim(args)
1661 out = capsys.readouterr().out
1662 assert "Task claimed" in out
1663
1664 def test_claim_text_output_on_empty(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1665 repo = _make_repo(tmp_path)
1666 args = _namespace(run_id="agent-1", queue=None, claim_ttl=3600, json_out=False)
1667 with _patch_repo(repo):
1668 with pytest.raises(SystemExit):
1669 run_claim(args)
1670 out = capsys.readouterr().out
1671 assert "empty" in out.lower()
1672
1673
1674 # ── New: claim hardening tests ────────────────────────────────────────────────
1675
1676 from muse.cli.commands.task_queue import (
1677 _MIN_CLAIM_TTL,
1678 _MAX_CLAIM_TTL,
1679 _MAX_WAIT_SECONDS,
1680 )
1681
1682
1683 def _claim_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
1684 """Build a Namespace with claim-appropriate defaults."""
1685 defaults = {
1686 "json_out": True,
1687 "run_id": "agent-1",
1688 "queue": None,
1689 "claim_ttl": 3600,
1690 "wait": 0,
1691 }
1692 defaults.update(kwargs)
1693 return argparse.Namespace(**defaults)
1694
1695
1696 class TestClaimInputValidation:
1697 """All claim validation fires before require_repo() — exit 1 on bad args."""
1698
1699 def test_run_id_at_max_length_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1700 repo = _make_repo(tmp_path)
1701 create_task(repo, "Task")
1702 args = _claim_ns(run_id="x" * _MAX_RUN_ID_LEN, json_out=True)
1703 with _patch_repo(repo):
1704 run_claim(args)
1705 out = json.loads(capsys.readouterr().out)
1706 assert out["claimer_run_id"] == "x" * _MAX_RUN_ID_LEN
1707
1708 def test_run_id_over_max_exits_1(self, tmp_path: pathlib.Path) -> None:
1709 repo = _make_repo(tmp_path)
1710 args = _claim_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1), json_out=True)
1711 with _patch_repo(repo):
1712 with pytest.raises(SystemExit) as exc:
1713 run_claim(args)
1714 assert exc.value.code == 1
1715
1716 def test_run_id_too_long_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1717 repo = _make_repo(tmp_path)
1718 args = _claim_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1), json_out=True)
1719 with _patch_repo(repo):
1720 with pytest.raises(SystemExit):
1721 run_claim(args)
1722 raw = capsys.readouterr().out.strip()
1723 data = json.loads(raw)
1724 assert "error" in data
1725 assert data["status"] == "bad_args"
1726
1727 def test_run_id_too_long_text_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1728 repo = _make_repo(tmp_path)
1729 args = _claim_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1), json_out=False)
1730 with _patch_repo(repo):
1731 with pytest.raises(SystemExit):
1732 run_claim(args)
1733 captured = capsys.readouterr()
1734 assert "❌" in captured.err
1735 assert captured.out == ""
1736
1737 def test_claim_ttl_min_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1738 repo = _make_repo(tmp_path)
1739 create_task(repo, "Task")
1740 args = _claim_ns(claim_ttl=_MIN_CLAIM_TTL, json_out=True)
1741 with _patch_repo(repo):
1742 run_claim(args)
1743 out = json.loads(capsys.readouterr().out)
1744 assert out["status"] == "claimed"
1745
1746 def test_claim_ttl_max_accepted(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1747 repo = _make_repo(tmp_path)
1748 create_task(repo, "Task")
1749 args = _claim_ns(claim_ttl=_MAX_CLAIM_TTL, json_out=True)
1750 with _patch_repo(repo):
1751 run_claim(args)
1752 out = json.loads(capsys.readouterr().out)
1753 assert out["status"] == "claimed"
1754
1755 def test_claim_ttl_zero_exits_1(self, tmp_path: pathlib.Path) -> None:
1756 repo = _make_repo(tmp_path)
1757 args = _claim_ns(claim_ttl=0, json_out=True)
1758 with _patch_repo(repo):
1759 with pytest.raises(SystemExit) as exc:
1760 run_claim(args)
1761 assert exc.value.code == 1
1762
1763 def test_claim_ttl_negative_exits_1(self, tmp_path: pathlib.Path) -> None:
1764 repo = _make_repo(tmp_path)
1765 args = _claim_ns(claim_ttl=-1, json_out=True)
1766 with _patch_repo(repo):
1767 with pytest.raises(SystemExit) as exc:
1768 run_claim(args)
1769 assert exc.value.code == 1
1770
1771 def test_claim_ttl_over_max_exits_1(self, tmp_path: pathlib.Path) -> None:
1772 repo = _make_repo(tmp_path)
1773 args = _claim_ns(claim_ttl=_MAX_CLAIM_TTL + 1, json_out=True)
1774 with _patch_repo(repo):
1775 with pytest.raises(SystemExit) as exc:
1776 run_claim(args)
1777 assert exc.value.code == 1
1778
1779 def test_claim_ttl_invalid_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1780 repo = _make_repo(tmp_path)
1781 args = _claim_ns(claim_ttl=0, json_out=True)
1782 with _patch_repo(repo):
1783 with pytest.raises(SystemExit):
1784 run_claim(args)
1785 raw = capsys.readouterr().out.strip()
1786 data = json.loads(raw)
1787 assert "error" in data
1788
1789 def test_wait_over_max_exits_1(self, tmp_path: pathlib.Path) -> None:
1790 repo = _make_repo(tmp_path)
1791 args = _claim_ns(wait=_MAX_WAIT_SECONDS + 1, json_out=True)
1792 with _patch_repo(repo):
1793 with pytest.raises(SystemExit) as exc:
1794 run_claim(args)
1795 assert exc.value.code == 1
1796
1797 def test_wait_negative_exits_1(self, tmp_path: pathlib.Path) -> None:
1798 repo = _make_repo(tmp_path)
1799 args = _claim_ns(wait=-1, json_out=True)
1800 with _patch_repo(repo):
1801 with pytest.raises(SystemExit) as exc:
1802 run_claim(args)
1803 assert exc.value.code == 1
1804
1805 def test_invalid_queue_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1806 repo = _make_repo(tmp_path)
1807 args = _claim_ns(queue="bad queue!", json_out=True)
1808 with _patch_repo(repo):
1809 with pytest.raises(SystemExit) as exc:
1810 run_claim(args)
1811 assert exc.value.code == 1
1812
1813 def test_invalid_queue_json_error(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1814 repo = _make_repo(tmp_path)
1815 args = _claim_ns(queue="bad queue!", json_out=True)
1816 with _patch_repo(repo):
1817 with pytest.raises(SystemExit):
1818 run_claim(args)
1819 raw = capsys.readouterr().out.strip()
1820 data = json.loads(raw)
1821 assert data["status"] == "bad_queue"
1822
1823 def test_queue_with_slash_exits_1(self, tmp_path: pathlib.Path) -> None:
1824 repo = _make_repo(tmp_path)
1825 args = _claim_ns(queue="../../etc/passwd", json_out=True)
1826 with _patch_repo(repo):
1827 with pytest.raises(SystemExit) as exc:
1828 run_claim(args)
1829 assert exc.value.code == 1
1830
1831 def test_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
1832 """require_repo must never be called when --run-id is too long."""
1833 repo = _make_repo(tmp_path)
1834 calls: list[bool] = []
1835
1836 def _fake_require() -> pathlib.Path:
1837 calls.append(True)
1838 return repo
1839
1840 args = _claim_ns(run_id="x" * (_MAX_RUN_ID_LEN + 1))
1841 with patch("muse.cli.commands.task_queue.require_repo", side_effect=_fake_require):
1842 with pytest.raises(SystemExit):
1843 run_claim(args)
1844 assert calls == [], "require_repo called before validation"
1845
1846 def test_valid_queue_name_filters_correctly(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1847 repo = _make_repo(tmp_path)
1848 create_task(repo, "billing task", queue="billing")
1849 args = _claim_ns(queue="billing", json_out=True)
1850 with _patch_repo(repo):
1851 run_claim(args)
1852 out = json.loads(capsys.readouterr().out)
1853 assert out["status"] == "claimed"
1854 assert out["task"]["queue"] == "billing"
1855
1856
1857 class TestClaimJsonOutput:
1858 """Compact JSON schema, single-line output, empty-queue schema."""
1859
1860 def test_success_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1861 repo = _make_repo(tmp_path)
1862 create_task(repo, "Task")
1863 args = _claim_ns(json_out=True)
1864 with _patch_repo(repo):
1865 run_claim(args)
1866 raw = capsys.readouterr().out.strip()
1867 assert "\n" not in raw
1868 json.loads(raw)
1869
1870 def test_success_json_schema_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1871 repo = _make_repo(tmp_path)
1872 create_task(repo, "Task")
1873 args = _claim_ns(json_out=True)
1874 with _patch_repo(repo):
1875 run_claim(args)
1876 out = json.loads(capsys.readouterr().out)
1877 for key in ("schema", "status", "task_id", "claimer_run_id",
1878 "claimed_at", "expires_at", "task", "duration_ms"):
1879 assert key in out, f"missing key: {key}"
1880
1881 def test_success_task_nested_schema(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1882 repo = _make_repo(tmp_path)
1883 create_task(repo, "Nested task", priority=5, queue="billing")
1884 args = _claim_ns(json_out=True)
1885 with _patch_repo(repo):
1886 run_claim(args)
1887 out = json.loads(capsys.readouterr().out)
1888 task = out["task"]
1889 assert task["title"] == "Nested task"
1890 assert task["priority"] == 5
1891 assert task["queue"] == "billing"
1892 for key in ("task_id", "title", "priority", "queue", "payload",
1893 "created_at", "created_by", "ttl_seconds", "tags"):
1894 assert key in task, f"task missing key: {key}"
1895
1896 def test_empty_queue_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1897 repo = _make_repo(tmp_path)
1898 args = _claim_ns(json_out=True)
1899 with _patch_repo(repo):
1900 with pytest.raises(SystemExit):
1901 run_claim(args)
1902 raw = capsys.readouterr().out.strip()
1903 assert "\n" not in raw
1904 json.loads(raw)
1905
1906 def test_empty_queue_json_schema(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1907 repo = _make_repo(tmp_path)
1908 args = _claim_ns(json_out=True, queue="myqueue")
1909 with _patch_repo(repo):
1910 with pytest.raises(SystemExit):
1911 run_claim(args)
1912 out = json.loads(capsys.readouterr().out)
1913 assert out["status"] == "empty"
1914 assert out["queue"] == "myqueue"
1915 assert "duration_ms" in out
1916 assert "schema" in out
1917
1918 def test_duration_ms_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1919 repo = _make_repo(tmp_path)
1920 create_task(repo, "Task")
1921 args = _claim_ns(json_out=True)
1922 with _patch_repo(repo):
1923 run_claim(args)
1924 out = json.loads(capsys.readouterr().out)
1925 assert isinstance(out["duration_ms"], float)
1926
1927 def test_claimer_run_id_matches_arg(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1928 repo = _make_repo(tmp_path)
1929 create_task(repo, "Task")
1930 args = _claim_ns(run_id="my-special-agent", json_out=True)
1931 with _patch_repo(repo):
1932 run_claim(args)
1933 out = json.loads(capsys.readouterr().out)
1934 assert out["claimer_run_id"] == "my-special-agent"
1935
1936
1937 class TestClaimTextOutput:
1938 """Text-format output is human-readable and ANSI-safe."""
1939
1940 def test_success_shows_task_claimed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1941 repo = _make_repo(tmp_path)
1942 create_task(repo, "My important task")
1943 args = _claim_ns(json_out=False)
1944 with _patch_repo(repo):
1945 run_claim(args)
1946 out = capsys.readouterr().out
1947 assert "Task claimed" in out
1948
1949 def test_success_shows_title(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1950 repo = _make_repo(tmp_path)
1951 create_task(repo, "Rename billing function")
1952 args = _claim_ns(json_out=False)
1953 with _patch_repo(repo):
1954 run_claim(args)
1955 out = capsys.readouterr().out
1956 assert "Rename billing function" in out
1957
1958 def test_success_shows_expires_in(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1959 repo = _make_repo(tmp_path)
1960 create_task(repo, "Task")
1961 args = _claim_ns(json_out=False, claim_ttl=1800)
1962 with _patch_repo(repo):
1963 run_claim(args)
1964 out = capsys.readouterr().out
1965 assert "Expires in" in out or "expires in" in out.lower()
1966
1967 def test_success_shows_payload(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1968 repo = _make_repo(tmp_path)
1969 create_task(repo, "Task", payload={"op": "rename"})
1970 args = _claim_ns(json_out=False)
1971 with _patch_repo(repo):
1972 run_claim(args)
1973 out = capsys.readouterr().out
1974 assert "rename" in out
1975
1976 def test_success_shows_tags(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1977 repo = _make_repo(tmp_path)
1978 create_task(repo, "Task", tags=["billing", "refactor"])
1979 args = _claim_ns(json_out=False)
1980 with _patch_repo(repo):
1981 run_claim(args)
1982 out = capsys.readouterr().out
1983 assert "billing" in out
1984
1985 def test_ansi_in_run_id_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1986 repo = _make_repo(tmp_path)
1987 create_task(repo, "Task")
1988 args = _claim_ns(run_id="\x1b[31mevil\x1b[0m", json_out=False)
1989 with _patch_repo(repo):
1990 run_claim(args)
1991 out = capsys.readouterr().out
1992 assert "\x1b" not in out
1993
1994 def test_ansi_in_title_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
1995 repo = _make_repo(tmp_path)
1996 create_task(repo, "\x1b[31mRed title\x1b[0m")
1997 args = _claim_ns(json_out=False)
1998 with _patch_repo(repo):
1999 run_claim(args)
2000 out = capsys.readouterr().out
2001 assert "\x1b" not in out
2002
2003 def test_empty_text_shows_empty(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2004 repo = _make_repo(tmp_path)
2005 args = _claim_ns(json_out=False, queue="billing")
2006 with _patch_repo(repo):
2007 with pytest.raises(SystemExit):
2008 run_claim(args)
2009 out = capsys.readouterr().out
2010 assert "empty" in out.lower()
2011 assert "billing" in out
2012
2013
2014 class TestClaimWait:
2015 """--wait polls until a task appears or the deadline expires."""
2016
2017 def test_wait_zero_returns_immediately_on_empty(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2018 repo = _make_repo(tmp_path)
2019 args = _claim_ns(wait=0, json_out=True)
2020 start = time.monotonic()
2021 with _patch_repo(repo):
2022 with pytest.raises(SystemExit) as exc:
2023 run_claim(args)
2024 elapsed = time.monotonic() - start
2025 assert exc.value.code == 1
2026 assert elapsed < 1.0
2027
2028 def test_wait_times_out_on_empty_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2029 repo = _make_repo(tmp_path)
2030 args = _claim_ns(wait=1, json_out=True) # 1 s wait
2031 start = time.monotonic()
2032 with _patch_repo(repo):
2033 with pytest.raises(SystemExit) as exc:
2034 run_claim(args)
2035 elapsed = time.monotonic() - start
2036 assert exc.value.code == 1
2037 assert elapsed >= 0.9, f"wait ended too early: {elapsed:.2f}s"
2038 out = json.loads(capsys.readouterr().out)
2039 assert out["status"] == "empty"
2040
2041 def test_wait_finds_task_created_during_wait(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2042 """Task created 0.3 s into a 3 s wait must be found."""
2043 repo = _make_repo(tmp_path)
2044
2045 def _create_after_delay() -> None:
2046 time.sleep(0.3)
2047 create_task(repo, "Delayed task")
2048
2049 t = threading.Thread(target=_create_after_delay, daemon=True)
2050 t.start()
2051
2052 args = _claim_ns(wait=3, json_out=True)
2053 with _patch_repo(repo):
2054 run_claim(args)
2055 t.join()
2056
2057 out = json.loads(capsys.readouterr().out)
2058 assert out["status"] == "claimed"
2059 assert out["task"]["title"] == "Delayed task"
2060
2061 def test_wait_empty_json_includes_elapsed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2062 repo = _make_repo(tmp_path)
2063 args = _claim_ns(wait=1, json_out=True)
2064 with _patch_repo(repo):
2065 with pytest.raises(SystemExit):
2066 run_claim(args)
2067 out = json.loads(capsys.readouterr().out)
2068 assert out["duration_ms"] >= 0.9
2069
2070 def test_wait_text_mentions_wait_on_empty(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2071 repo = _make_repo(tmp_path)
2072 args = _claim_ns(wait=1, json_out=False)
2073 with _patch_repo(repo):
2074 with pytest.raises(SystemExit):
2075 run_claim(args)
2076 out = capsys.readouterr().out
2077 # Text output should mention the wait duration
2078 assert "after" in out.lower() or "1" in out
2079
2080
2081 class TestClaimQueueFilter:
2082 """--queue restricts claiming to one named queue."""
2083
2084 def test_queue_filter_claims_correct_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2085 repo = _make_repo(tmp_path)
2086 create_task(repo, "billing task", queue="billing")
2087 create_task(repo, "auth task", queue="auth")
2088 args = _claim_ns(queue="auth", json_out=True)
2089 with _patch_repo(repo):
2090 run_claim(args)
2091 out = json.loads(capsys.readouterr().out)
2092 assert out["task"]["queue"] == "auth"
2093 assert out["task"]["title"] == "auth task"
2094
2095 def test_queue_filter_empty_when_wrong_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2096 repo = _make_repo(tmp_path)
2097 create_task(repo, "billing task", queue="billing")
2098 args = _claim_ns(queue="auth", json_out=True)
2099 with _patch_repo(repo):
2100 with pytest.raises(SystemExit) as exc:
2101 run_claim(args)
2102 assert exc.value.code == 1
2103 out = json.loads(capsys.readouterr().out)
2104 assert out["status"] == "empty"
2105
2106 def test_no_queue_filter_claims_highest_priority(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2107 repo = _make_repo(tmp_path)
2108 create_task(repo, "low priority", queue="q1", priority=0)
2109 create_task(repo, "high priority", queue="q2", priority=10)
2110 args = _claim_ns(queue=None, json_out=True)
2111 with _patch_repo(repo):
2112 run_claim(args)
2113 out = json.loads(capsys.readouterr().out)
2114 assert out["task"]["title"] == "high priority"
2115
2116 def test_queue_filter_with_valid_chars(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2117 repo = _make_repo(tmp_path)
2118 create_task(repo, "task", queue="my_queue-01")
2119 args = _claim_ns(queue="my_queue-01", json_out=True)
2120 with _patch_repo(repo):
2121 run_claim(args)
2122 out = json.loads(capsys.readouterr().out)
2123 assert out["status"] == "claimed"
2124
2125
2126 class TestClaimIntegration:
2127 """Full lifecycle: enqueue → claim → complete/fail."""
2128
2129 def test_claim_then_complete(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2130 repo = _make_repo(tmp_path)
2131 task = create_task(repo, "Complete me")
2132 args_c = _claim_ns(run_id="worker", json_out=True)
2133 with _patch_repo(repo):
2134 run_claim(args_c)
2135 out_c = json.loads(capsys.readouterr().out)
2136 assert out_c["status"] == "claimed"
2137
2138 args_done = _namespace(task_id=task.task_id, run_id="worker",
2139 result='{"ok": true}', json_out=True)
2140 with _patch_repo(repo):
2141 run_complete(args_done)
2142 out_done = json.loads(capsys.readouterr().out)
2143 assert out_done["status"] == "completed"
2144
2145 def test_claim_then_fail(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2146 repo = _make_repo(tmp_path)
2147 task = create_task(repo, "Fail me")
2148 args_c = _claim_ns(run_id="worker", json_out=True)
2149 with _patch_repo(repo):
2150 run_claim(args_c)
2151 capsys.readouterr()
2152
2153 args_f = _namespace(task_id=task.task_id, run_id="worker",
2154 error="network timeout", json_out=True)
2155 with _patch_repo(repo):
2156 run_fail_task(args_f)
2157 out = json.loads(capsys.readouterr().out)
2158 assert out["status"] == "failed"
2159
2160 def test_second_claim_empty_after_first(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2161 repo = _make_repo(tmp_path)
2162 create_task(repo, "Only task")
2163 args = _claim_ns(json_out=True)
2164 with _patch_repo(repo):
2165 run_claim(args)
2166 capsys.readouterr()
2167 with _patch_repo(repo):
2168 with pytest.raises(SystemExit) as exc:
2169 run_claim(args)
2170 assert exc.value.code == 1
2171
2172 def test_priority_order_across_claims(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2173 repo = _make_repo(tmp_path)
2174 create_task(repo, "Low", priority=0)
2175 create_task(repo, "High", priority=9)
2176 create_task(repo, "Mid", priority=5)
2177
2178 titles = []
2179 for _ in range(3):
2180 args = _claim_ns(json_out=True)
2181 with _patch_repo(repo):
2182 run_claim(args)
2183 out = json.loads(capsys.readouterr().out)
2184 titles.append(out["task"]["title"])
2185
2186 assert titles == ["High", "Mid", "Low"]
2187
2188 def test_enqueue_claim_complete_via_cli(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2189 """Full round-trip using CLI commands only."""
2190 repo = _make_repo(tmp_path)
2191
2192 # Enqueue
2193 args_e = _enqueue_ns(title="CLI round-trip", payload='{"x": 1}', json_out=True)
2194 with _patch_repo(repo):
2195 run_enqueue(args_e)
2196 enq = json.loads(capsys.readouterr().out)
2197 task_id = enq["task_id"]
2198
2199 # Claim
2200 args_c = _claim_ns(run_id="cli-worker", json_out=True)
2201 with _patch_repo(repo):
2202 run_claim(args_c)
2203 clm = json.loads(capsys.readouterr().out)
2204 assert clm["status"] == "claimed"
2205 assert clm["task"]["payload"]["x"] == 1
2206
2207 # Complete
2208 args_done = _namespace(task_id=task_id, run_id="cli-worker",
2209 result='{"done": true}', json_out=True)
2210 with _patch_repo(repo):
2211 run_complete(args_done)
2212 done = json.loads(capsys.readouterr().out)
2213 assert done["status"] == "completed"
2214
2215
2216 class TestClaimStress:
2217 """Concurrent claiming and performance under load."""
2218
2219 def test_concurrent_claim_no_double_claim(self, tmp_path: pathlib.Path) -> None:
2220 """10 tasks + 20 competing agents — no task claimed twice."""
2221 repo = _make_repo(tmp_path)
2222 for i in range(10):
2223 create_task(repo, f"Task {i}")
2224
2225 claimed_ids: list[str] = []
2226 lock = threading.Lock()
2227
2228 def _agent(n: int) -> None:
2229 try:
2230 result = claim_next_task(repo, f"agent-{n}")
2231 if result is not None:
2232 task, _ = result
2233 with lock:
2234 claimed_ids.append(task.task_id)
2235 except Exception:
2236 pass
2237
2238 threads = [threading.Thread(target=_agent, args=(i,)) for i in range(20)]
2239 for t in threads:
2240 t.start()
2241 for t in threads:
2242 t.join()
2243
2244 assert len(claimed_ids) == len(set(claimed_ids)), "task claimed twice"
2245 assert len(claimed_ids) <= 10
2246
2247 def test_claim_100_tasks_sequential(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2248 repo = _make_repo(tmp_path)
2249 for i in range(100):
2250 create_task(repo, f"Task {i}", queue="load")
2251
2252 start = time.monotonic()
2253 claimed = 0
2254 while claimed < 100:
2255 args = _claim_ns(queue="load", json_out=True)
2256 with _patch_repo(repo):
2257 try:
2258 run_claim(args)
2259 claimed += 1
2260 capsys.readouterr()
2261 except SystemExit:
2262 break
2263 elapsed = time.monotonic() - start
2264 assert claimed == 100
2265 assert elapsed < 15.0, f"100 sequential claims took {elapsed:.2f}s"
2266
2267 def test_wait_zero_performance(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2268 """--wait 0 on an empty queue must return in < 0.5 s."""
2269 repo = _make_repo(tmp_path)
2270 for _ in range(5):
2271 args = _claim_ns(wait=0, json_out=True)
2272 start = time.monotonic()
2273 with _patch_repo(repo):
2274 with pytest.raises(SystemExit):
2275 run_claim(args)
2276 elapsed = time.monotonic() - start
2277 assert elapsed < 0.5, f"wait=0 took {elapsed:.3f}s"
2278 capsys.readouterr()
2279
2280
2281 class TestCliComplete:
2282 """run_complete: success, wrong claimer, missing task."""
2283
2284 def _setup(self, repo: pathlib.Path) -> TaskRecord:
2285 t = create_task(repo, "Completable task")
2286 claim_next_task(repo, "agent-1")
2287 return t
2288
2289 def test_complete_success_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2290 repo = _make_repo(tmp_path)
2291 t = self._setup(repo)
2292 args = _namespace(task_id=t.task_id, run_id="agent-1", result="{}", json_out=True)
2293 with _patch_repo(repo):
2294 run_complete(args)
2295 out = json.loads(capsys.readouterr().out)
2296 assert out["status"] == "completed"
2297
2298 def test_complete_with_result(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2299 repo = _make_repo(tmp_path)
2300 t = self._setup(repo)
2301 args = _namespace(task_id=t.task_id, run_id="agent-1",
2302 result='{"pr": 99}', json_out=True)
2303 with _patch_repo(repo):
2304 run_complete(args)
2305 out = json.loads(capsys.readouterr().out)
2306 assert out["result"] == {"pr": 99}
2307
2308 def test_complete_wrong_claimer_exits_1(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2309 repo = _make_repo(tmp_path)
2310 t = self._setup(repo)
2311 args = _namespace(task_id=t.task_id, run_id="impostor", result="{}", json_out=True)
2312 with _patch_repo(repo):
2313 with pytest.raises(SystemExit) as exc:
2314 run_complete(args)
2315 assert exc.value.code == 1
2316
2317 def test_complete_invalid_result_exits_1(self, tmp_path: pathlib.Path) -> None:
2318 repo = _make_repo(tmp_path)
2319 t = self._setup(repo)
2320 args = _namespace(task_id=t.task_id, run_id="agent-1", result="NOTJSON", json_out=True)
2321 with _patch_repo(repo):
2322 with pytest.raises(SystemExit) as exc:
2323 run_complete(args)
2324 assert exc.value.code == 1
2325
2326 def test_complete_text_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2327 repo = _make_repo(tmp_path)
2328 t = self._setup(repo)
2329 args = _namespace(task_id=t.task_id, run_id="agent-1", result="{}", json_out=False)
2330 with _patch_repo(repo):
2331 run_complete(args)
2332 out = capsys.readouterr().out
2333 assert "completed" in out.lower()
2334
2335
2336 # ── complete hardening ────────────────────────────────────────────────────────
2337
2338 from muse.cli.commands.task_queue import _MAX_RESULT_BYTES
2339
2340
2341 def _complete_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
2342 """Build a Namespace with complete-appropriate defaults."""
2343 defaults = {
2344 "json_out": True,
2345 "run_id": "agent-1",
2346 "task_id": VALID_UUID,
2347 "result": "{}",
2348 }
2349 defaults.update(kwargs)
2350 return argparse.Namespace(**defaults)
2351
2352
2353 class TestCompleteInputValidation:
2354 """All complete validation fires before require_repo() and exits 1."""
2355
2356 def _setup(self, repo: pathlib.Path) -> TaskRecord:
2357 t = create_task(repo, "Complete-me")
2358 claim_next_task(repo, "agent-1")
2359 return t
2360
2361 def test_run_id_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
2362 repo = _make_repo(tmp_path)
2363 t = self._setup(repo)
2364 args = _complete_ns(task_id=t.task_id, run_id="x" * 257)
2365 with _patch_repo(repo):
2366 with pytest.raises(SystemExit) as exc:
2367 run_complete(args)
2368 assert exc.value.code == ExitCode.USER_ERROR
2369
2370 def test_run_id_at_max_length_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2371 repo = _make_repo(tmp_path)
2372 t = self._setup(repo)
2373 args = _complete_ns(task_id=t.task_id, run_id="a" * 256)
2374 # claim was made by "agent-1" — different run_id triggers PermissionError
2375 # but validation itself must pass (no bad_args exit before I/O)
2376 with _patch_repo(repo):
2377 with pytest.raises(SystemExit) as exc:
2378 run_complete(args)
2379 # exits 1 due to wrong claimer, NOT bad_args — validation passed
2380 out = json.loads(capsys.readouterr().out)
2381 assert out.get("status") != "bad_args"
2382
2383 def test_result_not_json_exits_1(self, tmp_path: pathlib.Path) -> None:
2384 repo = _make_repo(tmp_path)
2385 t = self._setup(repo)
2386 args = _complete_ns(task_id=t.task_id, result="not-json")
2387 with _patch_repo(repo):
2388 with pytest.raises(SystemExit) as exc:
2389 run_complete(args)
2390 assert exc.value.code == ExitCode.USER_ERROR
2391
2392 def test_result_array_exits_1(self, tmp_path: pathlib.Path) -> None:
2393 repo = _make_repo(tmp_path)
2394 t = self._setup(repo)
2395 args = _complete_ns(task_id=t.task_id, result="[1, 2, 3]")
2396 with _patch_repo(repo):
2397 with pytest.raises(SystemExit) as exc:
2398 run_complete(args)
2399 assert exc.value.code == ExitCode.USER_ERROR
2400
2401 def test_result_scalar_exits_1(self, tmp_path: pathlib.Path) -> None:
2402 repo = _make_repo(tmp_path)
2403 t = self._setup(repo)
2404 args = _complete_ns(task_id=t.task_id, result="42")
2405 with _patch_repo(repo):
2406 with pytest.raises(SystemExit) as exc:
2407 run_complete(args)
2408 assert exc.value.code == ExitCode.USER_ERROR
2409
2410 def test_result_null_exits_1(self, tmp_path: pathlib.Path) -> None:
2411 repo = _make_repo(tmp_path)
2412 t = self._setup(repo)
2413 args = _complete_ns(task_id=t.task_id, result="null")
2414 with _patch_repo(repo):
2415 with pytest.raises(SystemExit) as exc:
2416 run_complete(args)
2417 assert exc.value.code == ExitCode.USER_ERROR
2418
2419 def test_result_too_large_exits_1(self, tmp_path: pathlib.Path) -> None:
2420 repo = _make_repo(tmp_path)
2421 t = self._setup(repo)
2422 big = json.dumps({"k": "v" * (_MAX_RESULT_BYTES + 1)})
2423 args = _complete_ns(task_id=t.task_id, result=big)
2424 with _patch_repo(repo):
2425 with pytest.raises(SystemExit) as exc:
2426 run_complete(args)
2427 assert exc.value.code == ExitCode.USER_ERROR
2428
2429 def test_result_at_max_size_passes_validation(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2430 repo = _make_repo(tmp_path)
2431 t = self._setup(repo)
2432 # Build compact JSON exactly at the limit using explicit separators
2433 prefix = '{"k":"'
2434 suffix = '"}'
2435 inner = "x" * (_MAX_RESULT_BYTES - len(prefix) - len(suffix))
2436 payload = prefix + inner + suffix
2437 assert len(payload.encode()) == _MAX_RESULT_BYTES
2438 args = _complete_ns(task_id=t.task_id, result=payload)
2439 with _patch_repo(repo):
2440 run_complete(args)
2441 out = json.loads(capsys.readouterr().out)
2442 assert out["status"] == "completed"
2443
2444 def test_invalid_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2445 repo = _make_repo(tmp_path)
2446 args = _complete_ns(task_id="not-a-uuid")
2447 with _patch_repo(repo):
2448 with pytest.raises(SystemExit) as exc:
2449 run_complete(args)
2450 assert exc.value.code == ExitCode.USER_ERROR
2451
2452 def test_path_traversal_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2453 repo = _make_repo(tmp_path)
2454 args = _complete_ns(task_id="../../etc/passwd")
2455 with _patch_repo(repo):
2456 with pytest.raises(SystemExit) as exc:
2457 run_complete(args)
2458 assert exc.value.code == ExitCode.USER_ERROR
2459
2460 def test_null_byte_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2461 repo = _make_repo(tmp_path)
2462 args = _complete_ns(task_id="12345678-1234-4abc-8abc-123456789\x00ab")
2463 with _patch_repo(repo):
2464 with pytest.raises(SystemExit) as exc:
2465 run_complete(args)
2466 assert exc.value.code == ExitCode.USER_ERROR
2467
2468 def test_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
2469 """require_repo must NOT be called when task_id is invalid."""
2470 call_count = {"n": 0}
2471 original = __import__("muse.core.repo", fromlist=["require_repo"]).require_repo
2472
2473 def counting_require_repo() -> pathlib.Path:
2474 call_count["n"] += 1
2475 return original()
2476
2477 args = _complete_ns(task_id="BADUUID")
2478 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
2479 with pytest.raises(SystemExit):
2480 run_complete(args)
2481 assert call_count["n"] == 0, "require_repo was called before task_id validation"
2482
2483 def test_json_error_shape_bad_task_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2484 repo = _make_repo(tmp_path)
2485 args = _complete_ns(task_id="bad-id", json_out=True)
2486 with _patch_repo(repo):
2487 with pytest.raises(SystemExit):
2488 run_complete(args)
2489 out = json.loads(capsys.readouterr().out)
2490 assert "error" in out
2491 assert out["status"] == "bad_task_id"
2492
2493 def test_json_error_shape_bad_result(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2494 repo = _make_repo(tmp_path)
2495 args = _complete_ns(task_id=VALID_UUID, result="NOTJSON", json_out=True)
2496 with _patch_repo(repo):
2497 with pytest.raises(SystemExit):
2498 run_complete(args)
2499 out = json.loads(capsys.readouterr().out)
2500 assert "error" in out
2501 assert out["status"] == "bad_args"
2502
2503 def test_text_error_goes_to_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2504 repo = _make_repo(tmp_path)
2505 args = _complete_ns(task_id="BADUUID", json_out=False)
2506 with _patch_repo(repo):
2507 with pytest.raises(SystemExit):
2508 run_complete(args)
2509 captured = capsys.readouterr()
2510 assert captured.out == ""
2511 assert "❌" in captured.err
2512
2513 def test_json_error_goes_to_stdout_not_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2514 repo = _make_repo(tmp_path)
2515 args = _complete_ns(task_id="BADUUID", json_out=True)
2516 with _patch_repo(repo):
2517 with pytest.raises(SystemExit):
2518 run_complete(args)
2519 captured = capsys.readouterr()
2520 assert captured.err == ""
2521 out = json.loads(captured.out)
2522 assert "error" in out
2523
2524
2525 class TestCompleteJsonOutput:
2526 """run_complete JSON output shape and compactness."""
2527
2528 def _setup(self, repo: pathlib.Path) -> TaskRecord:
2529 t = create_task(repo, "JSON task", queue="billing")
2530 claim_next_task(repo, "completer-1")
2531 return t
2532
2533 def test_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2534 repo = _make_repo(tmp_path)
2535 t = self._setup(repo)
2536 args = _complete_ns(task_id=t.task_id, run_id="completer-1")
2537 with _patch_repo(repo):
2538 run_complete(args)
2539 raw = capsys.readouterr().out.strip()
2540 assert "\n" not in raw, "JSON output must be single line (compact)"
2541
2542 def test_json_has_required_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2543 repo = _make_repo(tmp_path)
2544 t = self._setup(repo)
2545 args = _complete_ns(task_id=t.task_id, run_id="completer-1")
2546 with _patch_repo(repo):
2547 run_complete(args)
2548 out = json.loads(capsys.readouterr().out)
2549 for key in ("schema", "task_id", "claimer_run_id", "status",
2550 "claimed_at", "expires_at", "result", "duration_ms"):
2551 assert key in out, f"missing key: {key}"
2552
2553 def test_json_status_is_completed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2554 repo = _make_repo(tmp_path)
2555 t = self._setup(repo)
2556 args = _complete_ns(task_id=t.task_id, run_id="completer-1")
2557 with _patch_repo(repo):
2558 run_complete(args)
2559 out = json.loads(capsys.readouterr().out)
2560 assert out["status"] == "completed"
2561
2562 def test_json_result_field_populated(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2563 repo = _make_repo(tmp_path)
2564 t = self._setup(repo)
2565 args = _complete_ns(task_id=t.task_id, run_id="completer-1",
2566 result='{"pr_url": "http://x/1"}')
2567 with _patch_repo(repo):
2568 run_complete(args)
2569 out = json.loads(capsys.readouterr().out)
2570 assert out["result"] == {"pr_url": "http://x/1"}
2571
2572 def test_json_empty_result_is_null(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2573 """Empty dict {} collapses to null in the claim (falsy check)."""
2574 repo = _make_repo(tmp_path)
2575 t = self._setup(repo)
2576 args = _complete_ns(task_id=t.task_id, run_id="completer-1", result="{}")
2577 with _patch_repo(repo):
2578 run_complete(args)
2579 out = json.loads(capsys.readouterr().out)
2580 assert out["result"] is None
2581
2582 def test_json_elapsed_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2583 repo = _make_repo(tmp_path)
2584 t = self._setup(repo)
2585 args = _complete_ns(task_id=t.task_id, run_id="completer-1")
2586 with _patch_repo(repo):
2587 run_complete(args)
2588 out = json.loads(capsys.readouterr().out)
2589 assert isinstance(out["duration_ms"], float)
2590
2591 def test_json_claimer_run_id_matches(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2592 repo = _make_repo(tmp_path)
2593 t = self._setup(repo)
2594 args = _complete_ns(task_id=t.task_id, run_id="completer-1")
2595 with _patch_repo(repo):
2596 run_complete(args)
2597 out = json.loads(capsys.readouterr().out)
2598 assert out["claimer_run_id"] == "completer-1"
2599
2600 def test_json_wrong_claimer_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2601 repo = _make_repo(tmp_path)
2602 t = self._setup(repo)
2603 args = _complete_ns(task_id=t.task_id, run_id="impostor", json_out=True)
2604 with _patch_repo(repo):
2605 with pytest.raises(SystemExit) as exc:
2606 run_complete(args)
2607 assert exc.value.code == 1
2608 out = json.loads(capsys.readouterr().out)
2609 assert "error" in out
2610
2611 def test_json_missing_task_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2612 repo = _make_repo(tmp_path)
2613 # repo exists but no task was created
2614 args = _complete_ns(task_id=VALID_UUID, run_id="completer-1", json_out=True)
2615 with _patch_repo(repo):
2616 with pytest.raises(SystemExit) as exc:
2617 run_complete(args)
2618 assert exc.value.code == 1
2619 out = json.loads(capsys.readouterr().out)
2620 assert "error" in out
2621
2622
2623 class TestCompleteTextOutput:
2624 """run_complete text output content."""
2625
2626 def _setup(self, repo: pathlib.Path, title: str = "My Task", queue: str = "default") -> TaskRecord:
2627 t = create_task(repo, title, queue=queue)
2628 claim_next_task(repo, "agent-1")
2629 return t
2630
2631 def test_text_shows_completed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2632 repo = _make_repo(tmp_path)
2633 t = self._setup(repo)
2634 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2635 with _patch_repo(repo):
2636 run_complete(args)
2637 out = capsys.readouterr().out
2638 assert "completed" in out.lower()
2639
2640 def test_text_shows_task_title(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2641 repo = _make_repo(tmp_path)
2642 t = self._setup(repo, title="Rename billing module")
2643 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2644 with _patch_repo(repo):
2645 run_complete(args)
2646 out = capsys.readouterr().out
2647 assert "Rename billing module" in out
2648
2649 def test_text_shows_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2650 repo = _make_repo(tmp_path)
2651 t = self._setup(repo, queue="billing")
2652 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2653 with _patch_repo(repo):
2654 run_complete(args)
2655 out = capsys.readouterr().out
2656 assert "billing" in out
2657
2658 def test_text_shows_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2659 repo = _make_repo(tmp_path)
2660 t = self._setup(repo)
2661 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2662 with _patch_repo(repo):
2663 run_complete(args)
2664 out = capsys.readouterr().out
2665 assert "agent-1" in out
2666
2667 def test_text_shows_result_when_provided(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2668 repo = _make_repo(tmp_path)
2669 t = self._setup(repo)
2670 args = _complete_ns(task_id=t.task_id, run_id="agent-1",
2671 result='{"pr": 42}', json_out=False)
2672 with _patch_repo(repo):
2673 run_complete(args)
2674 out = capsys.readouterr().out
2675 assert "42" in out
2676
2677 def test_text_shows_elapsed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2678 repo = _make_repo(tmp_path)
2679 t = self._setup(repo)
2680 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2681 with _patch_repo(repo):
2682 run_complete(args)
2683 out = capsys.readouterr().out
2684 assert "s)" in out # e.g. "(0.003s)"
2685
2686 def test_ansi_injection_in_run_id_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2687 evil_id = "agent\x1b[31mRED\x1b[0m"
2688 repo = _make_repo(tmp_path)
2689 t = create_task(repo, "task")
2690 claim_next_task(repo, evil_id)
2691 args = _complete_ns(task_id=t.task_id, run_id=evil_id, json_out=False)
2692 with _patch_repo(repo):
2693 run_complete(args)
2694 out = capsys.readouterr().out
2695 assert "\x1b" not in out
2696
2697 def test_ansi_injection_in_title_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2698 evil_title = "task\x1b[1mBOLD\x1b[0m"
2699 repo = _make_repo(tmp_path)
2700 t = create_task(repo, evil_title)
2701 claim_next_task(repo, "agent-1")
2702 args = _complete_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
2703 with _patch_repo(repo):
2704 run_complete(args)
2705 out = capsys.readouterr().out
2706 assert "\x1b" not in out
2707
2708
2709 class TestCompleteIntegration:
2710 """Full lifecycle integration tests for run_complete."""
2711
2712 def test_completed_status_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
2713 repo = _make_repo(tmp_path)
2714 t = create_task(repo, "persist-me")
2715 claim_next_task(repo, "worker")
2716 args = _complete_ns(task_id=t.task_id, run_id="worker")
2717 with _patch_repo(repo):
2718 run_complete(args)
2719 claim = load_claim(repo, t.task_id)
2720 assert claim is not None
2721 assert claim.status == "completed"
2722
2723 def test_result_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
2724 repo = _make_repo(tmp_path)
2725 t = create_task(repo, "result-me")
2726 claim_next_task(repo, "worker")
2727 args = _complete_ns(task_id=t.task_id, run_id="worker",
2728 result='{"sha": "abc123"}')
2729 with _patch_repo(repo):
2730 run_complete(args)
2731 claim = load_claim(repo, t.task_id)
2732 assert claim is not None
2733 assert claim.result == {"sha": "abc123"}
2734
2735 def test_double_complete_fails(self, tmp_path: pathlib.Path) -> None:
2736 repo = _make_repo(tmp_path)
2737 t = create_task(repo, "once only")
2738 claim_next_task(repo, "worker")
2739 args = _complete_ns(task_id=t.task_id, run_id="worker")
2740 with _patch_repo(repo):
2741 run_complete(args)
2742 with _patch_repo(repo):
2743 with pytest.raises(SystemExit) as exc:
2744 run_complete(args)
2745 assert exc.value.code == 1
2746
2747 def test_complete_missing_claim_fails(self, tmp_path: pathlib.Path) -> None:
2748 """Task exists but was never claimed."""
2749 repo = _make_repo(tmp_path)
2750 t = create_task(repo, "unclaimed")
2751 args = _complete_ns(task_id=t.task_id, run_id="worker")
2752 with _patch_repo(repo):
2753 with pytest.raises(SystemExit) as exc:
2754 run_complete(args)
2755 assert exc.value.code == 1
2756
2757 def test_enqueue_claim_complete_full_cycle(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2758 """Full CLI round-trip: enqueue → claim → complete."""
2759 repo = _make_repo(tmp_path)
2760 # enqueue
2761 eq_args = _enqueue_ns(title="e2e task", queue="default")
2762 with _patch_repo(repo):
2763 run_enqueue(eq_args)
2764 task_id = json.loads(capsys.readouterr().out)["task_id"]
2765 # claim
2766 cl_args = _claim_ns(run_id="e2e-agent")
2767 with _patch_repo(repo):
2768 run_claim(cl_args)
2769 capsys.readouterr()
2770 # complete
2771 co_args = _complete_ns(task_id=task_id, run_id="e2e-agent",
2772 result='{"done": true}')
2773 with _patch_repo(repo):
2774 run_complete(co_args)
2775 out = json.loads(capsys.readouterr().out)
2776 assert out["status"] == "completed"
2777 assert out["result"] == {"done": True}
2778
2779 def test_complete_with_unicode_result(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2780 repo = _make_repo(tmp_path)
2781 t = create_task(repo, "unicode")
2782 claim_next_task(repo, "agent-1")
2783 args = _complete_ns(task_id=t.task_id, run_id="agent-1",
2784 result='{"msg": "héllo wörld 🎉"}')
2785 with _patch_repo(repo):
2786 run_complete(args)
2787 out = json.loads(capsys.readouterr().out)
2788 assert out["result"]["msg"] == "héllo wörld 🎉"
2789
2790
2791 class TestCompleteStress:
2792 """Concurrency and throughput stress tests for run_complete."""
2793
2794 def test_20_agents_each_claim_and_complete_unique_task(self, tmp_path: pathlib.Path) -> None:
2795 """20 concurrent agents each claim+complete a unique task with no conflicts."""
2796 import concurrent.futures
2797 repo = _make_repo(tmp_path)
2798 tasks = [create_task(repo, f"task-{i}") for i in range(20)]
2799
2800 completed: set[str] = set()
2801 lock = threading.Lock()
2802 errors: list[str] = []
2803
2804 def claim_and_complete(task: "TaskRecord") -> None:
2805 run_id = f"agent-{task.task_id[:8]}"
2806 result = claim_next_task(repo, run_id, queue=task.queue)
2807 if result is None:
2808 errors.append(f"no task for {run_id}")
2809 return
2810 claimed_task, _ = result
2811 try:
2812 complete_task(repo, claimed_task.task_id, run_id)
2813 except Exception as exc: # noqa: BLE001
2814 errors.append(str(exc))
2815 return
2816 with lock:
2817 completed.add(claimed_task.task_id)
2818
2819 with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
2820 list(pool.map(claim_and_complete, tasks))
2821
2822 assert not errors, f"errors: {errors}"
2823 assert len(completed) == 20, f"only {len(completed)}/20 completed"
2824
2825 def test_100_sequential_completes_under_10s(self, tmp_path: pathlib.Path) -> None:
2826 repo = _make_repo(tmp_path)
2827 for i in range(100):
2828 t = create_task(repo, f"seq-{i}")
2829 claim_next_task(repo, "batch-worker")
2830 start = time.monotonic()
2831 complete_task(repo, t.task_id, "batch-worker")
2832 assert time.monotonic() - start < 0.15, f"task {i} took too long"
2833
2834 def test_complete_via_run_complete_100_sequential(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2835 repo = _make_repo(tmp_path)
2836 start = time.monotonic()
2837 for i in range(100):
2838 t = create_task(repo, f"cli-{i}")
2839 claim_next_task(repo, "cli-worker")
2840 args = _complete_ns(task_id=t.task_id, run_id="cli-worker")
2841 with _patch_repo(repo):
2842 run_complete(args)
2843 capsys.readouterr()
2844 elapsed = time.monotonic() - start
2845 assert elapsed < 15.0, f"100 CLI completes took {elapsed:.1f}s"
2846
2847
2848 class TestCliFailTask:
2849 """run_fail_task: success, wrong claimer, text/json output."""
2850
2851 def _setup(self, repo: pathlib.Path) -> TaskRecord:
2852 t = create_task(repo, "Failing task")
2853 claim_next_task(repo, "agent-1")
2854 return t
2855
2856 def test_fail_success_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2857 repo = _make_repo(tmp_path)
2858 t = self._setup(repo)
2859 args = _namespace(task_id=t.task_id, run_id="agent-1", error="network down", json_out=True)
2860 with _patch_repo(repo):
2861 run_fail_task(args)
2862 out = json.loads(capsys.readouterr().out)
2863 assert out["status"] == "failed"
2864 assert out["error"] == "network down"
2865
2866 def test_fail_wrong_claimer_exits_1(self, tmp_path: pathlib.Path) -> None:
2867 repo = _make_repo(tmp_path)
2868 t = self._setup(repo)
2869 args = _namespace(task_id=t.task_id, run_id="wrong", error="x", json_out=True)
2870 with _patch_repo(repo):
2871 with pytest.raises(SystemExit) as exc:
2872 run_fail_task(args)
2873 assert exc.value.code == 1
2874
2875 def test_fail_text_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2876 repo = _make_repo(tmp_path)
2877 t = self._setup(repo)
2878 args = _namespace(task_id=t.task_id, run_id="agent-1", error="boom", json_out=False)
2879 with _patch_repo(repo):
2880 run_fail_task(args)
2881 out = capsys.readouterr().out
2882 assert "failed" in out.lower()
2883
2884
2885 # ── fail-task hardening ───────────────────────────────────────────────────────
2886
2887 from muse.cli.commands.task_queue import _MAX_ERROR_LEN
2888
2889
2890 def _fail_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
2891 """Build a Namespace with fail-task-appropriate defaults."""
2892 defaults = {
2893 "json_out": True,
2894 "run_id": "agent-1",
2895 "task_id": VALID_UUID,
2896 "error": "something went wrong",
2897 }
2898 defaults.update(kwargs)
2899 return argparse.Namespace(**defaults)
2900
2901
2902 class TestFailTaskInputValidation:
2903 """All fail-task validation fires before require_repo() and exits 1."""
2904
2905 def _setup(self, repo: pathlib.Path) -> TaskRecord:
2906 t = create_task(repo, "Failable task")
2907 claim_next_task(repo, "agent-1")
2908 return t
2909
2910 def test_run_id_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
2911 repo = _make_repo(tmp_path)
2912 t = self._setup(repo)
2913 args = _fail_ns(task_id=t.task_id, run_id="x" * 257)
2914 with _patch_repo(repo):
2915 with pytest.raises(SystemExit) as exc:
2916 run_fail_task(args)
2917 assert exc.value.code == ExitCode.USER_ERROR
2918
2919 def test_run_id_at_max_length_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2920 repo = _make_repo(tmp_path)
2921 t = self._setup(repo)
2922 # 256-char run_id is valid length; mismatch with claimer triggers PermissionError
2923 args = _fail_ns(task_id=t.task_id, run_id="b" * 256)
2924 with _patch_repo(repo):
2925 with pytest.raises(SystemExit) as exc:
2926 run_fail_task(args)
2927 out = json.loads(capsys.readouterr().out)
2928 # Exits 1 due to wrong claimer, NOT bad_args — length validation passed
2929 assert out.get("status") != "bad_args"
2930
2931 def test_error_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
2932 repo = _make_repo(tmp_path)
2933 t = self._setup(repo)
2934 args = _fail_ns(task_id=t.task_id, error="e" * (_MAX_ERROR_LEN + 1))
2935 with _patch_repo(repo):
2936 with pytest.raises(SystemExit) as exc:
2937 run_fail_task(args)
2938 assert exc.value.code == ExitCode.USER_ERROR
2939
2940 def test_error_at_max_length_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2941 repo = _make_repo(tmp_path)
2942 t = self._setup(repo)
2943 args = _fail_ns(task_id=t.task_id, run_id="agent-1",
2944 error="e" * _MAX_ERROR_LEN)
2945 with _patch_repo(repo):
2946 run_fail_task(args)
2947 out = json.loads(capsys.readouterr().out)
2948 assert out["status"] == "failed"
2949
2950 def test_empty_error_allowed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
2951 repo = _make_repo(tmp_path)
2952 t = self._setup(repo)
2953 args = _fail_ns(task_id=t.task_id, run_id="agent-1", error="")
2954 with _patch_repo(repo):
2955 run_fail_task(args)
2956 out = json.loads(capsys.readouterr().out)
2957 assert out["status"] == "failed"
2958
2959 def test_invalid_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2960 repo = _make_repo(tmp_path)
2961 args = _fail_ns(task_id="not-a-uuid")
2962 with _patch_repo(repo):
2963 with pytest.raises(SystemExit) as exc:
2964 run_fail_task(args)
2965 assert exc.value.code == ExitCode.USER_ERROR
2966
2967 def test_path_traversal_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2968 repo = _make_repo(tmp_path)
2969 args = _fail_ns(task_id="../../etc/passwd")
2970 with _patch_repo(repo):
2971 with pytest.raises(SystemExit) as exc:
2972 run_fail_task(args)
2973 assert exc.value.code == ExitCode.USER_ERROR
2974
2975 def test_null_byte_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
2976 repo = _make_repo(tmp_path)
2977 args = _fail_ns(task_id="12345678-1234-4abc-8abc-123456789\x00ab")
2978 with _patch_repo(repo):
2979 with pytest.raises(SystemExit) as exc:
2980 run_fail_task(args)
2981 assert exc.value.code == ExitCode.USER_ERROR
2982
2983 def test_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
2984 """require_repo must NOT be called when task_id is invalid."""
2985 call_count = {"n": 0}
2986
2987 def counting_require_repo() -> pathlib.Path:
2988 call_count["n"] += 1
2989 raise RuntimeError("should not reach here")
2990
2991 args = _fail_ns(task_id="BADUUID")
2992 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
2993 with pytest.raises(SystemExit):
2994 run_fail_task(args)
2995 assert call_count["n"] == 0, "require_repo called before task_id validation"
2996
2997 def test_run_id_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
2998 call_count = {"n": 0}
2999
3000 def counting_require_repo() -> pathlib.Path:
3001 call_count["n"] += 1
3002 raise RuntimeError("should not reach here")
3003
3004 args = _fail_ns(task_id=VALID_UUID, run_id="r" * 300)
3005 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
3006 with pytest.raises(SystemExit):
3007 run_fail_task(args)
3008 assert call_count["n"] == 0
3009
3010 def test_error_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
3011 call_count = {"n": 0}
3012
3013 def counting_require_repo() -> pathlib.Path:
3014 call_count["n"] += 1
3015 raise RuntimeError("should not reach here")
3016
3017 args = _fail_ns(task_id=VALID_UUID, error="e" * 5000)
3018 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
3019 with pytest.raises(SystemExit):
3020 run_fail_task(args)
3021 assert call_count["n"] == 0
3022
3023 def test_json_error_shape_bad_task_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3024 repo = _make_repo(tmp_path)
3025 args = _fail_ns(task_id="bad-id", json_out=True)
3026 with _patch_repo(repo):
3027 with pytest.raises(SystemExit):
3028 run_fail_task(args)
3029 out = json.loads(capsys.readouterr().out)
3030 assert "error" in out
3031 assert out["status"] == "bad_task_id"
3032
3033 def test_json_error_shape_bad_run_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3034 repo = _make_repo(tmp_path)
3035 args = _fail_ns(task_id=VALID_UUID, run_id="x" * 300, json_out=True)
3036 with _patch_repo(repo):
3037 with pytest.raises(SystemExit):
3038 run_fail_task(args)
3039 out = json.loads(capsys.readouterr().out)
3040 assert "error" in out
3041 assert out["status"] == "bad_args"
3042
3043 def test_json_error_shape_bad_error_msg(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3044 repo = _make_repo(tmp_path)
3045 args = _fail_ns(task_id=VALID_UUID, error="e" * 5000, json_out=True)
3046 with _patch_repo(repo):
3047 with pytest.raises(SystemExit):
3048 run_fail_task(args)
3049 out = json.loads(capsys.readouterr().out)
3050 assert "error" in out
3051 assert out["status"] == "bad_args"
3052
3053 def test_text_error_goes_to_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3054 repo = _make_repo(tmp_path)
3055 args = _fail_ns(task_id="BADUUID", json_out=False)
3056 with _patch_repo(repo):
3057 with pytest.raises(SystemExit):
3058 run_fail_task(args)
3059 captured = capsys.readouterr()
3060 assert captured.out == ""
3061 assert "❌" in captured.err
3062
3063 def test_json_error_goes_to_stdout_not_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3064 repo = _make_repo(tmp_path)
3065 args = _fail_ns(task_id="BADUUID", json_out=True)
3066 with _patch_repo(repo):
3067 with pytest.raises(SystemExit):
3068 run_fail_task(args)
3069 captured = capsys.readouterr()
3070 assert captured.err == ""
3071 out = json.loads(captured.out)
3072 assert "error" in out
3073
3074
3075 class TestFailTaskJsonOutput:
3076 """run_fail_task JSON output shape and compactness."""
3077
3078 def _setup(self, repo: pathlib.Path, queue: str = "default") -> TaskRecord:
3079 t = create_task(repo, "JSON fail task", queue=queue)
3080 claim_next_task(repo, "failer-1")
3081 return t
3082
3083 def test_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3084 repo = _make_repo(tmp_path)
3085 t = self._setup(repo)
3086 args = _fail_ns(task_id=t.task_id, run_id="failer-1")
3087 with _patch_repo(repo):
3088 run_fail_task(args)
3089 raw = capsys.readouterr().out.strip()
3090 assert "\n" not in raw, "JSON output must be single line (compact)"
3091
3092 def test_json_has_required_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3093 repo = _make_repo(tmp_path)
3094 t = self._setup(repo)
3095 args = _fail_ns(task_id=t.task_id, run_id="failer-1")
3096 with _patch_repo(repo):
3097 run_fail_task(args)
3098 out = json.loads(capsys.readouterr().out)
3099 for key in ("schema", "task_id", "claimer_run_id", "status",
3100 "claimed_at", "expires_at", "error", "duration_ms"):
3101 assert key in out, f"missing key: {key}"
3102
3103 def test_json_status_is_failed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3104 repo = _make_repo(tmp_path)
3105 t = self._setup(repo)
3106 args = _fail_ns(task_id=t.task_id, run_id="failer-1")
3107 with _patch_repo(repo):
3108 run_fail_task(args)
3109 out = json.loads(capsys.readouterr().out)
3110 assert out["status"] == "failed"
3111
3112 def test_json_error_field_populated(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3113 repo = _make_repo(tmp_path)
3114 t = self._setup(repo)
3115 args = _fail_ns(task_id=t.task_id, run_id="failer-1",
3116 error="connection refused on port 5432")
3117 with _patch_repo(repo):
3118 run_fail_task(args)
3119 out = json.loads(capsys.readouterr().out)
3120 assert out["error"] == "connection refused on port 5432"
3121
3122 def test_json_empty_error_is_empty_string(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3123 repo = _make_repo(tmp_path)
3124 t = self._setup(repo)
3125 args = _fail_ns(task_id=t.task_id, run_id="failer-1", error="")
3126 with _patch_repo(repo):
3127 run_fail_task(args)
3128 out = json.loads(capsys.readouterr().out)
3129 assert out["error"] == ""
3130
3131 def test_json_elapsed_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3132 repo = _make_repo(tmp_path)
3133 t = self._setup(repo)
3134 args = _fail_ns(task_id=t.task_id, run_id="failer-1")
3135 with _patch_repo(repo):
3136 run_fail_task(args)
3137 out = json.loads(capsys.readouterr().out)
3138 assert isinstance(out["duration_ms"], float)
3139
3140 def test_json_claimer_run_id_matches(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3141 repo = _make_repo(tmp_path)
3142 t = self._setup(repo)
3143 args = _fail_ns(task_id=t.task_id, run_id="failer-1")
3144 with _patch_repo(repo):
3145 run_fail_task(args)
3146 out = json.loads(capsys.readouterr().out)
3147 assert out["claimer_run_id"] == "failer-1"
3148
3149 def test_json_wrong_claimer_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3150 repo = _make_repo(tmp_path)
3151 t = self._setup(repo)
3152 args = _fail_ns(task_id=t.task_id, run_id="impostor", json_out=True)
3153 with _patch_repo(repo):
3154 with pytest.raises(SystemExit) as exc:
3155 run_fail_task(args)
3156 assert exc.value.code == 1
3157 out = json.loads(capsys.readouterr().out)
3158 assert "error" in out
3159
3160 def test_json_missing_task_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3161 repo = _make_repo(tmp_path)
3162 args = _fail_ns(task_id=VALID_UUID, run_id="failer-1", json_out=True)
3163 with _patch_repo(repo):
3164 with pytest.raises(SystemExit) as exc:
3165 run_fail_task(args)
3166 assert exc.value.code == 1
3167 out = json.loads(capsys.readouterr().out)
3168 assert "error" in out
3169
3170
3171 class TestFailTaskTextOutput:
3172 """run_fail_task text output content."""
3173
3174 def _setup(self, repo: pathlib.Path, title: str = "My Task", queue: str = "default") -> TaskRecord:
3175 t = create_task(repo, title, queue=queue)
3176 claim_next_task(repo, "agent-1")
3177 return t
3178
3179 def test_text_shows_failed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3180 repo = _make_repo(tmp_path)
3181 t = self._setup(repo)
3182 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3183 with _patch_repo(repo):
3184 run_fail_task(args)
3185 out = capsys.readouterr().out
3186 assert "failed" in out.lower()
3187
3188 def test_text_shows_task_title(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3189 repo = _make_repo(tmp_path)
3190 t = self._setup(repo, title="Deploy billing service")
3191 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3192 with _patch_repo(repo):
3193 run_fail_task(args)
3194 out = capsys.readouterr().out
3195 assert "Deploy billing service" in out
3196
3197 def test_text_shows_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3198 repo = _make_repo(tmp_path)
3199 t = self._setup(repo, queue="infra")
3200 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3201 with _patch_repo(repo):
3202 run_fail_task(args)
3203 out = capsys.readouterr().out
3204 assert "infra" in out
3205
3206 def test_text_shows_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3207 repo = _make_repo(tmp_path)
3208 t = self._setup(repo)
3209 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3210 with _patch_repo(repo):
3211 run_fail_task(args)
3212 out = capsys.readouterr().out
3213 assert "agent-1" in out
3214
3215 def test_text_shows_error_message(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3216 repo = _make_repo(tmp_path)
3217 t = self._setup(repo)
3218 args = _fail_ns(task_id=t.task_id, run_id="agent-1",
3219 error="disk full on /data", json_out=False)
3220 with _patch_repo(repo):
3221 run_fail_task(args)
3222 out = capsys.readouterr().out
3223 assert "disk full on /data" in out
3224
3225 def test_text_no_error_line_when_empty(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3226 repo = _make_repo(tmp_path)
3227 t = self._setup(repo)
3228 args = _fail_ns(task_id=t.task_id, run_id="agent-1", error="", json_out=False)
3229 with _patch_repo(repo):
3230 run_fail_task(args)
3231 out = capsys.readouterr().out
3232 assert "Error:" not in out
3233
3234 def test_text_shows_elapsed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3235 repo = _make_repo(tmp_path)
3236 t = self._setup(repo)
3237 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3238 with _patch_repo(repo):
3239 run_fail_task(args)
3240 out = capsys.readouterr().out
3241 assert "s)" in out
3242
3243 def test_ansi_injection_in_error_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3244 repo = _make_repo(tmp_path)
3245 t = self._setup(repo)
3246 evil_err = "error\x1b[31mRED\x1b[0m"
3247 args = _fail_ns(task_id=t.task_id, run_id="agent-1",
3248 error=evil_err, json_out=False)
3249 with _patch_repo(repo):
3250 run_fail_task(args)
3251 out = capsys.readouterr().out
3252 assert "\x1b" not in out
3253
3254 def test_ansi_injection_in_title_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3255 evil_title = "task\x1b[1mBOLD\x1b[0m"
3256 repo = _make_repo(tmp_path)
3257 t = create_task(repo, evil_title)
3258 claim_next_task(repo, "agent-1")
3259 args = _fail_ns(task_id=t.task_id, run_id="agent-1", json_out=False)
3260 with _patch_repo(repo):
3261 run_fail_task(args)
3262 out = capsys.readouterr().out
3263 assert "\x1b" not in out
3264
3265
3266 class TestFailTaskIntegration:
3267 """Full lifecycle integration tests for run_fail_task."""
3268
3269 def test_failed_status_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
3270 repo = _make_repo(tmp_path)
3271 t = create_task(repo, "persist-fail")
3272 claim_next_task(repo, "worker")
3273 args = _fail_ns(task_id=t.task_id, run_id="worker", error="timeout")
3274 with _patch_repo(repo):
3275 run_fail_task(args)
3276 claim = load_claim(repo, t.task_id)
3277 assert claim is not None
3278 assert claim.status == "failed"
3279 assert claim.error == "timeout"
3280
3281 def test_error_persisted_to_disk(self, tmp_path: pathlib.Path) -> None:
3282 repo = _make_repo(tmp_path)
3283 t = create_task(repo, "error-persist")
3284 claim_next_task(repo, "worker")
3285 args = _fail_ns(task_id=t.task_id, run_id="worker",
3286 error="OOM at step 3: allocated 16 GiB")
3287 with _patch_repo(repo):
3288 run_fail_task(args)
3289 claim = load_claim(repo, t.task_id)
3290 assert claim is not None
3291 assert "OOM at step 3" in claim.error
3292
3293 def test_double_fail_exits_1(self, tmp_path: pathlib.Path) -> None:
3294 repo = _make_repo(tmp_path)
3295 t = create_task(repo, "double-fail")
3296 claim_next_task(repo, "worker")
3297 args = _fail_ns(task_id=t.task_id, run_id="worker")
3298 with _patch_repo(repo):
3299 run_fail_task(args)
3300 with _patch_repo(repo):
3301 with pytest.raises(SystemExit) as exc:
3302 run_fail_task(args)
3303 assert exc.value.code == 1
3304
3305 def test_fail_unclaimed_task_exits_1(self, tmp_path: pathlib.Path) -> None:
3306 repo = _make_repo(tmp_path)
3307 t = create_task(repo, "unclaimed")
3308 args = _fail_ns(task_id=t.task_id, run_id="worker")
3309 with _patch_repo(repo):
3310 with pytest.raises(SystemExit) as exc:
3311 run_fail_task(args)
3312 assert exc.value.code == 1
3313
3314 def test_enqueue_claim_fail_full_cycle(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3315 """Full CLI round-trip: enqueue → claim → fail."""
3316 repo = _make_repo(tmp_path)
3317 eq_args = _enqueue_ns(title="e2e fail task", queue="default")
3318 with _patch_repo(repo):
3319 run_enqueue(eq_args)
3320 task_id = json.loads(capsys.readouterr().out)["task_id"]
3321
3322 cl_args = _claim_ns(run_id="e2e-worker")
3323 with _patch_repo(repo):
3324 run_claim(cl_args)
3325 capsys.readouterr()
3326
3327 fa_args = _fail_ns(task_id=task_id, run_id="e2e-worker",
3328 error="dependency unavailable")
3329 with _patch_repo(repo):
3330 run_fail_task(fa_args)
3331 out = json.loads(capsys.readouterr().out)
3332 assert out["status"] == "failed"
3333 assert "dependency unavailable" in out["error"]
3334
3335 def test_unicode_error_message(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3336 repo = _make_repo(tmp_path)
3337 t = create_task(repo, "unicode-fail")
3338 claim_next_task(repo, "agent-1")
3339 args = _fail_ns(task_id=t.task_id, run_id="agent-1",
3340 error="échec: fichier introuvable — erreur 404 🚫")
3341 with _patch_repo(repo):
3342 run_fail_task(args)
3343 out = json.loads(capsys.readouterr().out)
3344 assert out["status"] == "failed"
3345 assert "échec" in out["error"]
3346
3347
3348 class TestFailTaskStress:
3349 """Concurrency and throughput stress tests for run_fail_task."""
3350
3351 def test_20_agents_each_claim_and_fail_unique_task(self, tmp_path: pathlib.Path) -> None:
3352 """20 concurrent agents each claim+fail a unique task with no conflicts."""
3353 import concurrent.futures
3354 repo = _make_repo(tmp_path)
3355 tasks = [create_task(repo, f"stress-{i}") for i in range(20)]
3356
3357 failed_ids: set[str] = set()
3358 lock = threading.Lock()
3359 errors: list[str] = []
3360
3361 def claim_and_fail(task: "TaskRecord") -> None:
3362 run_id = f"agent-{task.task_id[:8]}"
3363 result = claim_next_task(repo, run_id, queue=task.queue)
3364 if result is None:
3365 errors.append(f"no task for {run_id}")
3366 return
3367 claimed_task, _ = result
3368 try:
3369 fail_task(repo, claimed_task.task_id, run_id, error="stress test")
3370 except Exception as exc: # noqa: BLE001
3371 errors.append(str(exc))
3372 return
3373 with lock:
3374 failed_ids.add(claimed_task.task_id)
3375
3376 with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
3377 list(pool.map(claim_and_fail, tasks))
3378
3379 assert not errors, f"errors: {errors}"
3380 assert len(failed_ids) == 20, f"only {len(failed_ids)}/20 failed"
3381
3382 def test_100_sequential_fails_under_15s(self, tmp_path: pathlib.Path) -> None:
3383 repo = _make_repo(tmp_path)
3384 start = time.monotonic()
3385 for i in range(100):
3386 t = create_task(repo, f"seq-fail-{i}")
3387 claim_next_task(repo, "batch-failer")
3388 fail_task(repo, t.task_id, "batch-failer", error=f"error {i}")
3389 elapsed = time.monotonic() - start
3390 assert elapsed < 15.0, f"100 sequential fails took {elapsed:.1f}s"
3391
3392 def test_fail_via_run_fail_task_100_sequential(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3393 repo = _make_repo(tmp_path)
3394 start = time.monotonic()
3395 for i in range(100):
3396 t = create_task(repo, f"cli-fail-{i}")
3397 claim_next_task(repo, "cli-failer")
3398 args = _fail_ns(task_id=t.task_id, run_id="cli-failer",
3399 error=f"error {i}")
3400 with _patch_repo(repo):
3401 run_fail_task(args)
3402 capsys.readouterr()
3403 elapsed = time.monotonic() - start
3404 assert elapsed < 15.0, f"100 CLI fails took {elapsed:.1f}s"
3405
3406
3407 class TestCliCancelTask:
3408 """run_cancel_task: pending cancel, force cancel, error paths."""
3409
3410 def test_cancel_pending_task_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3411 repo = _make_repo(tmp_path)
3412 t = create_task(repo, "Unwanted task")
3413 args = _namespace(task_id=t.task_id, run_id="orchestrator", force=False, json_out=True)
3414 with _patch_repo(repo):
3415 run_cancel_task(args)
3416 out = json.loads(capsys.readouterr().out)
3417 assert out["status"] == "cancelled"
3418
3419 def test_cancel_claimed_by_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3420 repo = _make_repo(tmp_path)
3421 t = create_task(repo, "My task")
3422 claim_next_task(repo, "agent-1")
3423 args = _namespace(task_id=t.task_id, run_id="agent-1", force=False, json_out=True)
3424 with _patch_repo(repo):
3425 run_cancel_task(args)
3426 out = json.loads(capsys.readouterr().out)
3427 assert out["status"] == "cancelled"
3428
3429 def test_cancel_force_different_agent(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3430 repo = _make_repo(tmp_path)
3431 t = create_task(repo, "Stolen task")
3432 claim_next_task(repo, "agent-1")
3433 args = _namespace(task_id=t.task_id, run_id="orchestrator", force=True, json_out=True)
3434 with _patch_repo(repo):
3435 run_cancel_task(args)
3436 out = json.loads(capsys.readouterr().out)
3437 assert out["status"] == "cancelled"
3438
3439 def test_cancel_wrong_claimer_no_force_exits_1(self, tmp_path: pathlib.Path) -> None:
3440 repo = _make_repo(tmp_path)
3441 t = create_task(repo, "Someone else's task")
3442 claim_next_task(repo, "agent-1")
3443 args = _namespace(task_id=t.task_id, run_id="agent-2", force=False, json_out=True)
3444 with _patch_repo(repo):
3445 with pytest.raises(SystemExit) as exc:
3446 run_cancel_task(args)
3447 assert exc.value.code == 1
3448
3449 def test_cancel_text_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3450 repo = _make_repo(tmp_path)
3451 t = create_task(repo, "To cancel")
3452 args = _namespace(task_id=t.task_id, run_id="orchestrator", force=False, json_out=False)
3453 with _patch_repo(repo):
3454 run_cancel_task(args)
3455 out = capsys.readouterr().out
3456 assert "cancelled" in out.lower()
3457
3458
3459 # ── cancel-task hardening ─────────────────────────────────────────────────────
3460
3461
3462 def _cancel_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
3463 """Build a Namespace with cancel-task-appropriate defaults."""
3464 defaults = {
3465 "json_out": True,
3466 "run_id": "orchestrator",
3467 "task_id": VALID_UUID,
3468 "force": False,
3469 }
3470 defaults.update(kwargs)
3471 return argparse.Namespace(**defaults)
3472
3473
3474 class TestCancelTaskInputValidation:
3475 """All cancel-task validation fires before require_repo() and exits 1."""
3476
3477 def test_run_id_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
3478 repo = _make_repo(tmp_path)
3479 args = _cancel_ns(task_id=VALID_UUID, run_id="x" * 257)
3480 with _patch_repo(repo):
3481 with pytest.raises(SystemExit) as exc:
3482 run_cancel_task(args)
3483 assert exc.value.code == ExitCode.USER_ERROR
3484
3485 def test_run_id_at_max_length_passes_validation(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3486 repo = _make_repo(tmp_path)
3487 t = create_task(repo, "task")
3488 # run_id exactly at limit — passes length check; pending task cancels fine
3489 args = _cancel_ns(task_id=t.task_id, run_id="c" * 256)
3490 with _patch_repo(repo):
3491 run_cancel_task(args)
3492 out = json.loads(capsys.readouterr().out)
3493 assert out["status"] == "cancelled"
3494
3495 def test_invalid_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
3496 repo = _make_repo(tmp_path)
3497 args = _cancel_ns(task_id="not-a-uuid")
3498 with _patch_repo(repo):
3499 with pytest.raises(SystemExit) as exc:
3500 run_cancel_task(args)
3501 assert exc.value.code == ExitCode.USER_ERROR
3502
3503 def test_path_traversal_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
3504 repo = _make_repo(tmp_path)
3505 args = _cancel_ns(task_id="../../etc/passwd")
3506 with _patch_repo(repo):
3507 with pytest.raises(SystemExit) as exc:
3508 run_cancel_task(args)
3509 assert exc.value.code == ExitCode.USER_ERROR
3510
3511 def test_null_byte_task_id_exits_1(self, tmp_path: pathlib.Path) -> None:
3512 repo = _make_repo(tmp_path)
3513 args = _cancel_ns(task_id="12345678-1234-4abc-8abc-123456789\x00ab")
3514 with _patch_repo(repo):
3515 with pytest.raises(SystemExit) as exc:
3516 run_cancel_task(args)
3517 assert exc.value.code == ExitCode.USER_ERROR
3518
3519 def test_task_id_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
3520 call_count = {"n": 0}
3521
3522 def counting_require_repo() -> pathlib.Path:
3523 call_count["n"] += 1
3524 raise RuntimeError("should not be called")
3525
3526 args = _cancel_ns(task_id="BADUUID")
3527 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
3528 with pytest.raises(SystemExit):
3529 run_cancel_task(args)
3530 assert call_count["n"] == 0, "require_repo called before task_id validation"
3531
3532 def test_run_id_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
3533 call_count = {"n": 0}
3534
3535 def counting_require_repo() -> pathlib.Path:
3536 call_count["n"] += 1
3537 raise RuntimeError("should not be called")
3538
3539 args = _cancel_ns(task_id=VALID_UUID, run_id="r" * 300)
3540 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
3541 with pytest.raises(SystemExit):
3542 run_cancel_task(args)
3543 assert call_count["n"] == 0
3544
3545 def test_json_error_shape_bad_task_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3546 repo = _make_repo(tmp_path)
3547 args = _cancel_ns(task_id="bad-id", json_out=True)
3548 with _patch_repo(repo):
3549 with pytest.raises(SystemExit):
3550 run_cancel_task(args)
3551 out = json.loads(capsys.readouterr().out)
3552 assert "error" in out
3553 assert out["status"] == "bad_task_id"
3554
3555 def test_json_error_shape_bad_run_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3556 repo = _make_repo(tmp_path)
3557 args = _cancel_ns(task_id=VALID_UUID, run_id="x" * 300, json_out=True)
3558 with _patch_repo(repo):
3559 with pytest.raises(SystemExit):
3560 run_cancel_task(args)
3561 out = json.loads(capsys.readouterr().out)
3562 assert "error" in out
3563 assert out["status"] == "bad_args"
3564
3565 def test_text_error_goes_to_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3566 repo = _make_repo(tmp_path)
3567 args = _cancel_ns(task_id="BADUUID", json_out=False)
3568 with _patch_repo(repo):
3569 with pytest.raises(SystemExit):
3570 run_cancel_task(args)
3571 captured = capsys.readouterr()
3572 assert captured.out == ""
3573 assert "❌" in captured.err
3574
3575 def test_json_error_goes_to_stdout_not_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3576 repo = _make_repo(tmp_path)
3577 args = _cancel_ns(task_id="BADUUID", json_out=True)
3578 with _patch_repo(repo):
3579 with pytest.raises(SystemExit):
3580 run_cancel_task(args)
3581 captured = capsys.readouterr()
3582 assert captured.err == ""
3583 out = json.loads(captured.out)
3584 assert "error" in out
3585
3586 def test_missing_task_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3587 repo = _make_repo(tmp_path)
3588 # Valid UUID but task does not exist
3589 args = _cancel_ns(task_id=VALID_UUID, json_out=True)
3590 with _patch_repo(repo):
3591 with pytest.raises(SystemExit) as exc:
3592 run_cancel_task(args)
3593 assert exc.value.code == 1
3594 out = json.loads(capsys.readouterr().out)
3595 assert "error" in out
3596
3597 def test_already_terminal_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3598 repo = _make_repo(tmp_path)
3599 t = create_task(repo, "terminal")
3600 claim_next_task(repo, "agent-1")
3601 complete_task(repo, t.task_id, "agent-1")
3602 args = _cancel_ns(task_id=t.task_id, run_id="agent-1", json_out=True)
3603 with _patch_repo(repo):
3604 with pytest.raises(SystemExit) as exc:
3605 run_cancel_task(args)
3606 assert exc.value.code == 1
3607 out = json.loads(capsys.readouterr().out)
3608 assert "error" in out
3609
3610 def test_wrong_claimer_no_force_error_shape(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3611 repo = _make_repo(tmp_path)
3612 t = create_task(repo, "owned")
3613 claim_next_task(repo, "agent-1")
3614 args = _cancel_ns(task_id=t.task_id, run_id="agent-2",
3615 force=False, json_out=True)
3616 with _patch_repo(repo):
3617 with pytest.raises(SystemExit) as exc:
3618 run_cancel_task(args)
3619 assert exc.value.code == 1
3620 out = json.loads(capsys.readouterr().out)
3621 assert "error" in out
3622
3623
3624 class TestCancelTaskJsonOutput:
3625 """run_cancel_task JSON output shape and compactness."""
3626
3627 def test_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3628 repo = _make_repo(tmp_path)
3629 t = create_task(repo, "compact task")
3630 args = _cancel_ns(task_id=t.task_id)
3631 with _patch_repo(repo):
3632 run_cancel_task(args)
3633 raw = capsys.readouterr().out.strip()
3634 assert "\n" not in raw, "JSON output must be single line (compact)"
3635
3636 def test_json_has_required_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3637 repo = _make_repo(tmp_path)
3638 t = create_task(repo, "keys task")
3639 args = _cancel_ns(task_id=t.task_id)
3640 with _patch_repo(repo):
3641 run_cancel_task(args)
3642 out = json.loads(capsys.readouterr().out)
3643 for key in ("schema", "task_id", "claimer_run_id", "status",
3644 "claimed_at", "expires_at", "error", "duration_ms"):
3645 assert key in out, f"missing key: {key}"
3646
3647 def test_json_status_is_cancelled(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3648 repo = _make_repo(tmp_path)
3649 t = create_task(repo, "status task")
3650 args = _cancel_ns(task_id=t.task_id)
3651 with _patch_repo(repo):
3652 run_cancel_task(args)
3653 out = json.loads(capsys.readouterr().out)
3654 assert out["status"] == "cancelled"
3655
3656 def test_json_elapsed_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3657 repo = _make_repo(tmp_path)
3658 t = create_task(repo, "elapsed task")
3659 args = _cancel_ns(task_id=t.task_id)
3660 with _patch_repo(repo):
3661 run_cancel_task(args)
3662 out = json.loads(capsys.readouterr().out)
3663 assert isinstance(out["duration_ms"], float)
3664
3665 def test_json_claimer_run_id_for_pending_is_caller(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3666 """For a pending task, claimer_run_id is the calling agent."""
3667 repo = _make_repo(tmp_path)
3668 t = create_task(repo, "pending task")
3669 args = _cancel_ns(task_id=t.task_id, run_id="orchestrator-99")
3670 with _patch_repo(repo):
3671 run_cancel_task(args)
3672 out = json.loads(capsys.readouterr().out)
3673 assert out["claimer_run_id"] == "orchestrator-99"
3674
3675 def test_json_claimer_run_id_for_claimed_is_original_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3676 """For a claimed task cancelled by its claimer, run_id is preserved."""
3677 repo = _make_repo(tmp_path)
3678 t = create_task(repo, "claimed task")
3679 claim_next_task(repo, "agent-xyz")
3680 args = _cancel_ns(task_id=t.task_id, run_id="agent-xyz")
3681 with _patch_repo(repo):
3682 run_cancel_task(args)
3683 out = json.loads(capsys.readouterr().out)
3684 assert out["claimer_run_id"] == "agent-xyz"
3685
3686 def test_json_force_cancel_different_agent(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3687 repo = _make_repo(tmp_path)
3688 t = create_task(repo, "force task")
3689 claim_next_task(repo, "agent-1")
3690 args = _cancel_ns(task_id=t.task_id, run_id="orchestrator", force=True)
3691 with _patch_repo(repo):
3692 run_cancel_task(args)
3693 out = json.loads(capsys.readouterr().out)
3694 assert out["status"] == "cancelled"
3695
3696 def test_json_task_id_matches(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3697 repo = _make_repo(tmp_path)
3698 t = create_task(repo, "id task")
3699 args = _cancel_ns(task_id=t.task_id)
3700 with _patch_repo(repo):
3701 run_cancel_task(args)
3702 out = json.loads(capsys.readouterr().out)
3703 assert out["task_id"] == t.task_id
3704
3705
3706 class TestCancelTaskTextOutput:
3707 """run_cancel_task text output content."""
3708
3709 def _setup_pending(self, repo: pathlib.Path, title: str = "Pending Task",
3710 queue: str = "default") -> TaskRecord:
3711 return create_task(repo, title, queue=queue)
3712
3713 def _setup_claimed(self, repo: pathlib.Path, title: str = "Claimed Task",
3714 queue: str = "default", claimer: str = "agent-1") -> TaskRecord:
3715 t = create_task(repo, title, queue=queue)
3716 claim_next_task(repo, claimer)
3717 return t
3718
3719 def test_text_shows_cancelled(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3720 repo = _make_repo(tmp_path)
3721 t = self._setup_pending(repo)
3722 args = _cancel_ns(task_id=t.task_id, json_out=False)
3723 with _patch_repo(repo):
3724 run_cancel_task(args)
3725 out = capsys.readouterr().out
3726 assert "cancelled" in out.lower()
3727
3728 def test_text_shows_task_title(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3729 repo = _make_repo(tmp_path)
3730 t = self._setup_pending(repo, title="Decommission old infra")
3731 args = _cancel_ns(task_id=t.task_id, json_out=False)
3732 with _patch_repo(repo):
3733 run_cancel_task(args)
3734 out = capsys.readouterr().out
3735 assert "Decommission old infra" in out
3736
3737 def test_text_shows_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3738 repo = _make_repo(tmp_path)
3739 t = self._setup_pending(repo, queue="infra")
3740 args = _cancel_ns(task_id=t.task_id, json_out=False)
3741 with _patch_repo(repo):
3742 run_cancel_task(args)
3743 out = capsys.readouterr().out
3744 assert "infra" in out
3745
3746 def test_text_shows_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3747 repo = _make_repo(tmp_path)
3748 t = self._setup_pending(repo)
3749 args = _cancel_ns(task_id=t.task_id, run_id="orch-42", json_out=False)
3750 with _patch_repo(repo):
3751 run_cancel_task(args)
3752 out = capsys.readouterr().out
3753 assert "orch-42" in out
3754
3755 def test_text_shows_forced_indicator(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3756 repo = _make_repo(tmp_path)
3757 t = self._setup_claimed(repo, claimer="agent-1")
3758 args = _cancel_ns(task_id=t.task_id, run_id="orch", force=True, json_out=False)
3759 with _patch_repo(repo):
3760 run_cancel_task(args)
3761 out = capsys.readouterr().out
3762 assert "forced" in out.lower()
3763
3764 def test_text_no_forced_indicator_without_flag(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3765 repo = _make_repo(tmp_path)
3766 t = self._setup_claimed(repo, claimer="agent-1")
3767 args = _cancel_ns(task_id=t.task_id, run_id="agent-1",
3768 force=False, json_out=False)
3769 with _patch_repo(repo):
3770 run_cancel_task(args)
3771 out = capsys.readouterr().out
3772 assert "forced" not in out.lower()
3773
3774 def test_text_shows_elapsed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3775 repo = _make_repo(tmp_path)
3776 t = self._setup_pending(repo)
3777 args = _cancel_ns(task_id=t.task_id, json_out=False)
3778 with _patch_repo(repo):
3779 run_cancel_task(args)
3780 out = capsys.readouterr().out
3781 assert "s)" in out
3782
3783 def test_ansi_injection_in_title_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3784 evil_title = "task\x1b[1mBOLD\x1b[0m"
3785 repo = _make_repo(tmp_path)
3786 t = create_task(repo, evil_title)
3787 args = _cancel_ns(task_id=t.task_id, json_out=False)
3788 with _patch_repo(repo):
3789 run_cancel_task(args)
3790 out = capsys.readouterr().out
3791 assert "\x1b" not in out
3792
3793 def test_ansi_injection_in_run_id_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3794 evil_id = "orch\x1b[31mRED\x1b[0m"
3795 repo = _make_repo(tmp_path)
3796 t = create_task(repo, "task")
3797 args = _cancel_ns(task_id=t.task_id, run_id=evil_id, json_out=False)
3798 with _patch_repo(repo):
3799 run_cancel_task(args)
3800 out = capsys.readouterr().out
3801 assert "\x1b" not in out
3802
3803
3804 class TestCancelTaskIntegration:
3805 """Full lifecycle integration tests for run_cancel_task."""
3806
3807 def test_pending_task_cancelled_status_on_disk(self, tmp_path: pathlib.Path) -> None:
3808 repo = _make_repo(tmp_path)
3809 t = create_task(repo, "pending-cancel")
3810 args = _cancel_ns(task_id=t.task_id, run_id="orch")
3811 with _patch_repo(repo):
3812 run_cancel_task(args)
3813 claim = load_claim(repo, t.task_id)
3814 assert claim is not None
3815 assert claim.status == "cancelled"
3816
3817 def test_claimed_task_cancelled_by_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3818 repo = _make_repo(tmp_path)
3819 t = create_task(repo, "claimed-cancel")
3820 claim_next_task(repo, "worker")
3821 args = _cancel_ns(task_id=t.task_id, run_id="worker")
3822 with _patch_repo(repo):
3823 run_cancel_task(args)
3824 out = json.loads(capsys.readouterr().out)
3825 assert out["status"] == "cancelled"
3826 claim = load_claim(repo, t.task_id)
3827 assert claim.status == "cancelled"
3828
3829 def test_force_cancel_different_claimer(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3830 repo = _make_repo(tmp_path)
3831 t = create_task(repo, "force-cancel")
3832 claim_next_task(repo, "agent-1")
3833 args = _cancel_ns(task_id=t.task_id, run_id="orchestrator", force=True)
3834 with _patch_repo(repo):
3835 run_cancel_task(args)
3836 out = json.loads(capsys.readouterr().out)
3837 assert out["status"] == "cancelled"
3838
3839 def test_double_cancel_exits_1(self, tmp_path: pathlib.Path) -> None:
3840 repo = _make_repo(tmp_path)
3841 t = create_task(repo, "double-cancel")
3842 args = _cancel_ns(task_id=t.task_id)
3843 with _patch_repo(repo):
3844 run_cancel_task(args)
3845 with _patch_repo(repo):
3846 with pytest.raises(SystemExit) as exc:
3847 run_cancel_task(args)
3848 assert exc.value.code == 1
3849
3850 def test_cancel_completed_task_exits_1(self, tmp_path: pathlib.Path) -> None:
3851 repo = _make_repo(tmp_path)
3852 t = create_task(repo, "completed-task")
3853 claim_next_task(repo, "agent-1")
3854 complete_task(repo, t.task_id, "agent-1")
3855 args = _cancel_ns(task_id=t.task_id, run_id="agent-1")
3856 with _patch_repo(repo):
3857 with pytest.raises(SystemExit) as exc:
3858 run_cancel_task(args)
3859 assert exc.value.code == 1
3860
3861 def test_cancel_failed_task_exits_1(self, tmp_path: pathlib.Path) -> None:
3862 repo = _make_repo(tmp_path)
3863 t = create_task(repo, "failed-task")
3864 claim_next_task(repo, "agent-1")
3865 fail_task(repo, t.task_id, "agent-1", error="boom")
3866 args = _cancel_ns(task_id=t.task_id, run_id="agent-1")
3867 with _patch_repo(repo):
3868 with pytest.raises(SystemExit) as exc:
3869 run_cancel_task(args)
3870 assert exc.value.code == 1
3871
3872 def test_enqueue_then_cancel_full_cycle(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3873 """Full CLI round-trip: enqueue → cancel."""
3874 repo = _make_repo(tmp_path)
3875 eq_args = _enqueue_ns(title="e2e cancel", queue="default")
3876 with _patch_repo(repo):
3877 run_enqueue(eq_args)
3878 task_id = json.loads(capsys.readouterr().out)["task_id"]
3879
3880 ca_args = _cancel_ns(task_id=task_id, run_id="orch")
3881 with _patch_repo(repo):
3882 run_cancel_task(ca_args)
3883 out = json.loads(capsys.readouterr().out)
3884 assert out["status"] == "cancelled"
3885 assert out["task_id"] == task_id
3886
3887 def test_enqueue_claim_cancel_full_cycle(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3888 """Full CLI round-trip: enqueue → claim → cancel by claimer."""
3889 repo = _make_repo(tmp_path)
3890 eq_args = _enqueue_ns(title="e2e claim-cancel", queue="default")
3891 with _patch_repo(repo):
3892 run_enqueue(eq_args)
3893 task_id = json.loads(capsys.readouterr().out)["task_id"]
3894
3895 cl_args = _claim_ns(run_id="e2e-worker")
3896 with _patch_repo(repo):
3897 run_claim(cl_args)
3898 capsys.readouterr()
3899
3900 ca_args = _cancel_ns(task_id=task_id, run_id="e2e-worker")
3901 with _patch_repo(repo):
3902 run_cancel_task(ca_args)
3903 out = json.loads(capsys.readouterr().out)
3904 assert out["status"] == "cancelled"
3905
3906
3907 class TestCancelTaskStress:
3908 """Concurrency and throughput stress tests for run_cancel_task."""
3909
3910 def test_20_agents_each_cancel_unique_pending_task(self, tmp_path: pathlib.Path) -> None:
3911 """20 concurrent orchestrators each cancel a distinct pending task."""
3912 import concurrent.futures
3913 repo = _make_repo(tmp_path)
3914 tasks = [create_task(repo, f"cancel-stress-{i}") for i in range(20)]
3915
3916 cancelled_ids: set[str] = set()
3917 lock = threading.Lock()
3918 errors: list[str] = []
3919
3920 def do_cancel(t: "TaskRecord") -> None:
3921 try:
3922 claim = cancel_task(repo, t.task_id, f"orch-{t.task_id[:8]}")
3923 except Exception as exc: # noqa: BLE001
3924 errors.append(str(exc))
3925 return
3926 with lock:
3927 cancelled_ids.add(claim.task_id)
3928
3929 with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
3930 list(pool.map(do_cancel, tasks))
3931
3932 assert not errors, f"errors: {errors}"
3933 assert len(cancelled_ids) == 20, f"only {len(cancelled_ids)}/20 cancelled"
3934
3935 def test_100_sequential_cancels_under_15s(self, tmp_path: pathlib.Path) -> None:
3936 repo = _make_repo(tmp_path)
3937 start = time.monotonic()
3938 for i in range(100):
3939 t = create_task(repo, f"seq-cancel-{i}")
3940 cancel_task(repo, t.task_id, "orch")
3941 elapsed = time.monotonic() - start
3942 assert elapsed < 15.0, f"100 sequential cancels took {elapsed:.1f}s"
3943
3944 def test_cancel_via_run_cancel_task_100_sequential(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3945 repo = _make_repo(tmp_path)
3946 start = time.monotonic()
3947 for i in range(100):
3948 t = create_task(repo, f"cli-cancel-{i}")
3949 args = _cancel_ns(task_id=t.task_id, run_id="orch")
3950 with _patch_repo(repo):
3951 run_cancel_task(args)
3952 capsys.readouterr()
3953 elapsed = time.monotonic() - start
3954 assert elapsed < 15.0, f"100 CLI cancels took {elapsed:.1f}s"
3955
3956
3957 class TestCliTasks:
3958 """run_tasks: listing, filtering, JSON/text output."""
3959
3960 def _setup_mixed(self, repo: pathlib.Path) -> tuple[TaskRecord, TaskRecord, TaskRecord]:
3961 """Create tasks in various states."""
3962 t1 = create_task(repo, "Pending task", queue="q1", priority=1)
3963 t2 = create_task(repo, "Claimed task", queue="q2", priority=5)
3964 t3 = create_task(repo, "Done task", queue="q1", priority=3)
3965 claim_next_task(repo, "agent-2", queue="q2")
3966 claim_next_task(repo, "agent-3", queue="q1")
3967 complete_task(repo, t3.task_id, "agent-3")
3968 return t1, t2, t3
3969
3970 def test_lists_all_tasks_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3971 repo = _make_repo(tmp_path)
3972 self._setup_mixed(repo)
3973 args = _namespace(json_out=True, status=None, queue=None, run_id=None)
3974 with _patch_repo(repo):
3975 run_tasks(args)
3976 out = json.loads(capsys.readouterr().out)
3977 assert out["total"] == 3
3978 assert len(out["items"]) == 3
3979
3980 def test_filter_by_status_pending(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3981 repo = _make_repo(tmp_path)
3982 self._setup_mixed(repo)
3983 args = _namespace(json_out=True, status="pending", queue=None, run_id=None)
3984 with _patch_repo(repo):
3985 run_tasks(args)
3986 out = json.loads(capsys.readouterr().out)
3987 for item in out["items"]:
3988 assert item["status"] == "pending"
3989
3990 def test_filter_by_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
3991 repo = _make_repo(tmp_path)
3992 self._setup_mixed(repo)
3993 args = _namespace(json_out=True, status=None, queue="q1", run_id=None)
3994 with _patch_repo(repo):
3995 run_tasks(args)
3996 out = json.loads(capsys.readouterr().out)
3997 for item in out["items"]:
3998 assert item["queue"] == "q1"
3999
4000 def test_filter_by_run_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4001 repo = _make_repo(tmp_path)
4002 self._setup_mixed(repo)
4003 args = _namespace(json_out=True, status=None, queue=None, run_id="agent-2")
4004 with _patch_repo(repo):
4005 run_tasks(args)
4006 out = json.loads(capsys.readouterr().out)
4007 for item in out["items"]:
4008 assert item["claimer_run_id"] == "agent-2"
4009
4010 def test_items_sorted_by_priority_desc(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4011 repo = _make_repo(tmp_path)
4012 with _freeze(_EPOCH):
4013 create_task(repo, "Low", priority=0)
4014 create_task(repo, "High", priority=10)
4015 create_task(repo, "Mid", priority=5)
4016 args = _namespace(json_out=True, status=None, queue=None, run_id=None)
4017 with _patch_repo(repo):
4018 run_tasks(args)
4019 out = json.loads(capsys.readouterr().out)
4020 priorities = [i["priority"] for i in out["items"]]
4021 assert priorities == sorted(priorities, reverse=True)
4022
4023 def test_text_output_no_crash(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4024 repo = _make_repo(tmp_path)
4025 create_task(repo, "Text test task")
4026 args = _namespace(json_out=False, status=None, queue=None, run_id=None)
4027 with _patch_repo(repo):
4028 run_tasks(args)
4029 out = capsys.readouterr().out
4030 assert "Task queue" in out
4031
4032 def test_empty_queue_text_output(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4033 repo = _make_repo(tmp_path)
4034 args = _namespace(json_out=False, status=None, queue=None, run_id=None)
4035 with _patch_repo(repo):
4036 run_tasks(args)
4037 out = capsys.readouterr().out
4038 assert "0 task" in out
4039
4040 def test_status_counts_in_json(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4041 repo = _make_repo(tmp_path)
4042 create_task(repo, "Pending")
4043 args = _namespace(json_out=True, status=None, queue=None, run_id=None)
4044 with _patch_repo(repo):
4045 run_tasks(args)
4046 out = json.loads(capsys.readouterr().out)
4047 assert "pending" in out
4048 assert out["pending"] == 1
4049
4050 def test_ansi_in_title_not_printed_raw(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4051 """ANSI escape sequences in task titles must be sanitized in text output."""
4052 repo = _make_repo(tmp_path)
4053 create_task(repo, "\x1b[31mRED\x1b[0m")
4054 args = _namespace(json_out=False, status=None, queue=None, run_id=None)
4055 with _patch_repo(repo):
4056 run_tasks(args)
4057 out = capsys.readouterr().out
4058 # Raw ESC byte must not appear in text output
4059 assert "\x1b" not in out
4060
4061
4062 # ── tasks hardening ───────────────────────────────────────────────────────────
4063
4064 from muse.cli.commands.task_queue import _MAX_LIMIT
4065 from muse.core._types import Manifest
4066
4067
4068 def _tasks_ns(**kwargs: MsgpackValue) -> argparse.Namespace:
4069 """Build a Namespace with tasks-appropriate defaults."""
4070 defaults = {
4071 "json_out": True,
4072 "status": None,
4073 "queue": None,
4074 "run_id": None,
4075 "limit": 200,
4076 }
4077 defaults.update(kwargs)
4078 return argparse.Namespace(**defaults)
4079
4080
4081 class TestTasksInputValidation:
4082 """All tasks validation fires before require_repo() and exits 1."""
4083
4084 def test_invalid_queue_name_exits_1(self, tmp_path: pathlib.Path) -> None:
4085 repo = _make_repo(tmp_path)
4086 args = _tasks_ns(queue="bad queue!")
4087 with _patch_repo(repo):
4088 with pytest.raises(SystemExit) as exc:
4089 run_tasks(args)
4090 assert exc.value.code == ExitCode.USER_ERROR
4091
4092 def test_queue_with_slash_exits_1(self, tmp_path: pathlib.Path) -> None:
4093 repo = _make_repo(tmp_path)
4094 args = _tasks_ns(queue="../../etc")
4095 with _patch_repo(repo):
4096 with pytest.raises(SystemExit) as exc:
4097 run_tasks(args)
4098 assert exc.value.code == ExitCode.USER_ERROR
4099
4100 def test_queue_with_ansi_exits_1(self, tmp_path: pathlib.Path) -> None:
4101 repo = _make_repo(tmp_path)
4102 args = _tasks_ns(queue="q\x1b[31m")
4103 with _patch_repo(repo):
4104 with pytest.raises(SystemExit) as exc:
4105 run_tasks(args)
4106 assert exc.value.code == ExitCode.USER_ERROR
4107
4108 def test_valid_queue_name_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4109 repo = _make_repo(tmp_path)
4110 create_task(repo, "task", queue="billing-v2")
4111 args = _tasks_ns(queue="billing-v2")
4112 with _patch_repo(repo):
4113 run_tasks(args)
4114 out = json.loads(capsys.readouterr().out)
4115 assert out["total"] >= 0 # no exception
4116
4117 def test_run_id_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
4118 repo = _make_repo(tmp_path)
4119 args = _tasks_ns(run_id="x" * 257)
4120 with _patch_repo(repo):
4121 with pytest.raises(SystemExit) as exc:
4122 run_tasks(args)
4123 assert exc.value.code == ExitCode.USER_ERROR
4124
4125 def test_run_id_at_max_length_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4126 repo = _make_repo(tmp_path)
4127 args = _tasks_ns(run_id="r" * 256)
4128 with _patch_repo(repo):
4129 run_tasks(args)
4130 out = json.loads(capsys.readouterr().out)
4131 assert "items" in out
4132
4133 def test_limit_zero_exits_1(self, tmp_path: pathlib.Path) -> None:
4134 repo = _make_repo(tmp_path)
4135 args = _tasks_ns(limit=0)
4136 with _patch_repo(repo):
4137 with pytest.raises(SystemExit) as exc:
4138 run_tasks(args)
4139 assert exc.value.code == ExitCode.USER_ERROR
4140
4141 def test_limit_negative_exits_1(self, tmp_path: pathlib.Path) -> None:
4142 repo = _make_repo(tmp_path)
4143 args = _tasks_ns(limit=-1)
4144 with _patch_repo(repo):
4145 with pytest.raises(SystemExit) as exc:
4146 run_tasks(args)
4147 assert exc.value.code == ExitCode.USER_ERROR
4148
4149 def test_limit_over_max_exits_1(self, tmp_path: pathlib.Path) -> None:
4150 repo = _make_repo(tmp_path)
4151 args = _tasks_ns(limit=_MAX_LIMIT + 1)
4152 with _patch_repo(repo):
4153 with pytest.raises(SystemExit) as exc:
4154 run_tasks(args)
4155 assert exc.value.code == ExitCode.USER_ERROR
4156
4157 def test_limit_at_max_passes(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4158 repo = _make_repo(tmp_path)
4159 args = _tasks_ns(limit=_MAX_LIMIT)
4160 with _patch_repo(repo):
4161 run_tasks(args)
4162 out = json.loads(capsys.readouterr().out)
4163 assert "items" in out
4164
4165 def test_queue_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
4166 call_count = {"n": 0}
4167
4168 def counting_require_repo() -> pathlib.Path:
4169 call_count["n"] += 1
4170 raise RuntimeError("should not be called")
4171
4172 args = _tasks_ns(queue="bad queue!")
4173 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
4174 with pytest.raises(SystemExit):
4175 run_tasks(args)
4176 assert call_count["n"] == 0
4177
4178 def test_run_id_validation_fires_before_repo_lookup(self, tmp_path: pathlib.Path) -> None:
4179 call_count = {"n": 0}
4180
4181 def counting_require_repo() -> pathlib.Path:
4182 call_count["n"] += 1
4183 raise RuntimeError("should not be called")
4184
4185 args = _tasks_ns(run_id="r" * 300)
4186 with patch("muse.cli.commands.task_queue.require_repo", counting_require_repo):
4187 with pytest.raises(SystemExit):
4188 run_tasks(args)
4189 assert call_count["n"] == 0
4190
4191 def test_json_error_shape_bad_queue(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4192 repo = _make_repo(tmp_path)
4193 args = _tasks_ns(queue="bad queue!", json_out=True)
4194 with _patch_repo(repo):
4195 with pytest.raises(SystemExit):
4196 run_tasks(args)
4197 out = json.loads(capsys.readouterr().out)
4198 assert "error" in out
4199 assert out["status"] == "bad_queue"
4200
4201 def test_json_error_shape_bad_limit(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4202 repo = _make_repo(tmp_path)
4203 args = _tasks_ns(limit=0, json_out=True)
4204 with _patch_repo(repo):
4205 with pytest.raises(SystemExit):
4206 run_tasks(args)
4207 out = json.loads(capsys.readouterr().out)
4208 assert "error" in out
4209 assert out["status"] == "bad_args"
4210
4211 def test_text_error_goes_to_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4212 repo = _make_repo(tmp_path)
4213 args = _tasks_ns(queue="bad queue!", json_out=False)
4214 with _patch_repo(repo):
4215 with pytest.raises(SystemExit):
4216 run_tasks(args)
4217 captured = capsys.readouterr()
4218 assert captured.out == ""
4219 assert "❌" in captured.err
4220
4221 def test_json_error_goes_to_stdout_not_stderr(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4222 repo = _make_repo(tmp_path)
4223 args = _tasks_ns(queue="bad queue!", json_out=True)
4224 with _patch_repo(repo):
4225 with pytest.raises(SystemExit):
4226 run_tasks(args)
4227 captured = capsys.readouterr()
4228 assert captured.err == ""
4229 out = json.loads(captured.out)
4230 assert "error" in out
4231
4232
4233 class TestTasksJsonOutput:
4234 """run_tasks JSON output shape, compactness, and field completeness."""
4235
4236 def _setup(self, repo: pathlib.Path) -> tuple[TaskRecord, TaskRecord, TaskRecord]:
4237 t1 = create_task(repo, "Pending", queue="q1", priority=1)
4238 t2 = create_task(repo, "Claimed", queue="q2", priority=5)
4239 t3 = create_task(repo, "Done", queue="q1", priority=3)
4240 claim_next_task(repo, "worker-a", queue="q2")
4241 claim_next_task(repo, "worker-b", queue="q1")
4242 complete_task(repo, t3.task_id, "worker-b")
4243 return t1, t2, t3
4244
4245 def test_json_is_compact(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4246 repo = _make_repo(tmp_path)
4247 self._setup(repo)
4248 args = _tasks_ns()
4249 with _patch_repo(repo):
4250 run_tasks(args)
4251 raw = capsys.readouterr().out.strip()
4252 assert "\n" not in raw, "JSON must be single line (compact)"
4253
4254 def test_json_has_top_level_keys(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4255 repo = _make_repo(tmp_path)
4256 self._setup(repo)
4257 args = _tasks_ns()
4258 with _patch_repo(repo):
4259 run_tasks(args)
4260 out = json.loads(capsys.readouterr().out)
4261 for key in ("schema", "total", "pending", "claimed", "timed_out",
4262 "completed", "failed", "cancelled", "limit", "truncated",
4263 "items", "duration_ms"):
4264 assert key in out, f"missing top-level key: {key}"
4265
4266 def test_items_have_new_fields(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4267 repo = _make_repo(tmp_path)
4268 create_task(repo, "field-test", queue="default")
4269 args = _tasks_ns()
4270 with _patch_repo(repo):
4271 run_tasks(args)
4272 out = json.loads(capsys.readouterr().out)
4273 assert len(out["items"]) == 1
4274 item = out["items"][0]
4275 for field in ("created_by", "ttl_seconds", "expires_at"):
4276 assert field in item, f"missing item field: {field}"
4277
4278 def test_expires_at_null_for_pending_task(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4279 repo = _make_repo(tmp_path)
4280 create_task(repo, "pending-task")
4281 args = _tasks_ns()
4282 with _patch_repo(repo):
4283 run_tasks(args)
4284 out = json.loads(capsys.readouterr().out)
4285 item = out["items"][0]
4286 assert item["expires_at"] is None
4287
4288 def test_expires_at_populated_for_claimed_task(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4289 repo = _make_repo(tmp_path)
4290 create_task(repo, "claimed-task")
4291 claim_next_task(repo, "worker")
4292 args = _tasks_ns()
4293 with _patch_repo(repo):
4294 run_tasks(args)
4295 out = json.loads(capsys.readouterr().out)
4296 item = out["items"][0]
4297 assert item["expires_at"] is not None
4298 # Should be a parseable ISO 8601 datetime
4299 import datetime as _dt
4300 _dt.datetime.fromisoformat(item["expires_at"])
4301
4302 def test_status_counts_reflect_full_queue_when_filtered(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4303 """Counts always reflect ALL tasks, not just the filtered set."""
4304 repo = _make_repo(tmp_path)
4305 self._setup(repo)
4306 # Filter to only q1 items, but total/counts should still be 3
4307 args = _tasks_ns(queue="q1")
4308 with _patch_repo(repo):
4309 run_tasks(args)
4310 out = json.loads(capsys.readouterr().out)
4311 assert out["total"] == 3
4312 assert len(out["items"]) == 2 # only q1 items
4313
4314 def test_limit_truncates_items(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4315 repo = _make_repo(tmp_path)
4316 for i in range(10):
4317 create_task(repo, f"task-{i}")
4318 args = _tasks_ns(limit=3)
4319 with _patch_repo(repo):
4320 run_tasks(args)
4321 out = json.loads(capsys.readouterr().out)
4322 assert len(out["items"]) == 3
4323 assert out["truncated"] is True
4324 assert out["limit"] == 3
4325 assert out["total"] == 10 # full count still correct
4326
4327 def test_no_truncation_when_within_limit(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4328 repo = _make_repo(tmp_path)
4329 for i in range(5):
4330 create_task(repo, f"task-{i}")
4331 args = _tasks_ns(limit=10)
4332 with _patch_repo(repo):
4333 run_tasks(args)
4334 out = json.loads(capsys.readouterr().out)
4335 assert len(out["items"]) == 5
4336 assert out["truncated"] is False
4337
4338 def test_elapsed_is_float(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4339 repo = _make_repo(tmp_path)
4340 args = _tasks_ns()
4341 with _patch_repo(repo):
4342 run_tasks(args)
4343 out = json.loads(capsys.readouterr().out)
4344 assert isinstance(out["duration_ms"], float)
4345
4346 def test_items_sorted_priority_desc(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4347 repo = _make_repo(tmp_path)
4348 with _freeze(_EPOCH):
4349 create_task(repo, "Low", priority=0)
4350 create_task(repo, "High", priority=10)
4351 create_task(repo, "Mid", priority=5)
4352 args = _tasks_ns()
4353 with _patch_repo(repo):
4354 run_tasks(args)
4355 out = json.loads(capsys.readouterr().out)
4356 priorities = [i["priority"] for i in out["items"]]
4357 assert priorities == sorted(priorities, reverse=True)
4358
4359 def test_limit_applies_after_sort(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4360 """--limit=1 should return the highest-priority task."""
4361 repo = _make_repo(tmp_path)
4362 with _freeze(_EPOCH):
4363 create_task(repo, "Low", priority=0)
4364 create_task(repo, "High", priority=99)
4365 args = _tasks_ns(limit=1)
4366 with _patch_repo(repo):
4367 run_tasks(args)
4368 out = json.loads(capsys.readouterr().out)
4369 assert len(out["items"]) == 1
4370 assert out["items"][0]["priority"] == 99
4371
4372
4373 class TestTasksTextOutput:
4374 """run_tasks text output content and formatting."""
4375
4376 def test_text_shows_task_queue_header(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4377 repo = _make_repo(tmp_path)
4378 args = _tasks_ns(json_out=False)
4379 with _patch_repo(repo):
4380 run_tasks(args)
4381 out = capsys.readouterr().out
4382 assert "Task queue" in out
4383
4384 def test_text_shows_status_counts(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4385 repo = _make_repo(tmp_path)
4386 create_task(repo, "pending")
4387 args = _tasks_ns(json_out=False)
4388 with _patch_repo(repo):
4389 run_tasks(args)
4390 out = capsys.readouterr().out
4391 assert "pending" in out
4392 assert "claimed" in out
4393
4394 def test_text_shows_column_headers(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4395 repo = _make_repo(tmp_path)
4396 create_task(repo, "task")
4397 args = _tasks_ns(json_out=False)
4398 with _patch_repo(repo):
4399 run_tasks(args)
4400 out = capsys.readouterr().out
4401 assert "ID" in out
4402 assert "QUEUE" in out
4403 assert "TITLE" in out
4404
4405 def test_text_shows_filter_line(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4406 repo = _make_repo(tmp_path)
4407 args = _tasks_ns(json_out=False, queue="billing")
4408 with _patch_repo(repo):
4409 run_tasks(args)
4410 out = capsys.readouterr().out
4411 assert "filter" in out
4412 assert "billing" in out
4413
4414 def test_text_empty_queue_message(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4415 repo = _make_repo(tmp_path)
4416 args = _tasks_ns(json_out=False)
4417 with _patch_repo(repo):
4418 run_tasks(args)
4419 out = capsys.readouterr().out
4420 assert "0 task" in out
4421
4422 def test_text_shows_elapsed(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4423 repo = _make_repo(tmp_path)
4424 args = _tasks_ns(json_out=False)
4425 with _patch_repo(repo):
4426 run_tasks(args)
4427 out = capsys.readouterr().out
4428 assert "s)" in out
4429
4430 def test_ansi_in_queue_name_stripped(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4431 """Enqueued tasks with ANSI in queue are sanitized in text output."""
4432 # We can't enqueue with a bad queue via CLI (validated), but the task
4433 # record could be crafted; sanitize_display handles it in display.
4434 repo = _make_repo(tmp_path)
4435 create_task(repo, "task")
4436 args = _tasks_ns(json_out=False, run_id="\x1b[31mred\x1b[0m")
4437 # run_id with ANSI is valid length-wise but we're checking display
4438 with _patch_repo(repo):
4439 run_tasks(args)
4440 out = capsys.readouterr().out
4441 assert "\x1b" not in out
4442
4443
4444 class TestTasksIntegration:
4445 """Full integration tests for run_tasks with realistic task states."""
4446
4447 def test_full_mixed_state_counts(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4448 repo = _make_repo(tmp_path)
4449 # pending (queue="p" — no worker claims these)
4450 create_task(repo, "p1", queue="p")
4451 create_task(repo, "p2", queue="p")
4452 # claimed (queue="c")
4453 create_task(repo, "c1", queue="c")
4454 claim_next_task(repo, "w1", queue="c")
4455 # completed (queue="done")
4456 t_done = create_task(repo, "done", queue="done")
4457 claim_next_task(repo, "w2", queue="done")
4458 complete_task(repo, t_done.task_id, "w2")
4459 # failed (queue="fail")
4460 t_fail = create_task(repo, "fail", queue="fail")
4461 claim_next_task(repo, "w3", queue="fail")
4462 fail_task(repo, t_fail.task_id, "w3", error="boom")
4463 # cancelled
4464 t_can = create_task(repo, "cancelled", queue="can")
4465 cancel_task(repo, t_can.task_id, "orch")
4466
4467 args = _tasks_ns()
4468 with _patch_repo(repo):
4469 run_tasks(args)
4470 out = json.loads(capsys.readouterr().out)
4471 assert out["total"] == 6
4472 assert out["pending"] == 2
4473 assert out["claimed"] == 1
4474 assert out["completed"] == 1
4475 assert out["failed"] == 1
4476 assert out["cancelled"] == 1
4477
4478 def test_filter_by_status_only_returns_matching(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4479 repo = _make_repo(tmp_path)
4480 create_task(repo, "pending-1", queue="p")
4481 t_done = create_task(repo, "done-1", queue="done")
4482 claim_next_task(repo, "w", queue="done")
4483 complete_task(repo, t_done.task_id, "w")
4484
4485 args = _tasks_ns(status="completed")
4486 with _patch_repo(repo):
4487 run_tasks(args)
4488 out = json.loads(capsys.readouterr().out)
4489 assert all(i["status"] == "completed" for i in out["items"])
4490 assert len(out["items"]) == 1
4491 # But total counts still reflect full queue
4492 assert out["total"] == 2
4493
4494 def test_filter_by_queue_and_status(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4495 repo = _make_repo(tmp_path)
4496 # Create and immediately complete a billing task (no competing tasks yet)
4497 t_b = create_task(repo, "billing-done", queue="billing")
4498 claim_next_task(repo, "w", queue="billing")
4499 complete_task(repo, t_b.task_id, "w")
4500 # Now add a pending billing and an infra task
4501 create_task(repo, "billing-pending", queue="billing")
4502 create_task(repo, "infra-pending", queue="infra")
4503
4504 args = _tasks_ns(queue="billing", status="completed")
4505 with _patch_repo(repo):
4506 run_tasks(args)
4507 out = json.loads(capsys.readouterr().out)
4508 assert len(out["items"]) == 1
4509 assert out["items"][0]["queue"] == "billing"
4510 assert out["items"][0]["status"] == "completed"
4511 # Global total is all 3
4512 assert out["total"] == 3
4513
4514 def test_filter_by_run_id(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4515 repo = _make_repo(tmp_path)
4516 t1 = create_task(repo, "t1")
4517 t2 = create_task(repo, "t2")
4518 claim_next_task(repo, "worker-alpha")
4519 claim_next_task(repo, "worker-beta")
4520
4521 args = _tasks_ns(run_id="worker-alpha")
4522 with _patch_repo(repo):
4523 run_tasks(args)
4524 out = json.loads(capsys.readouterr().out)
4525 assert len(out["items"]) == 1
4526 assert out["items"][0]["claimer_run_id"] == "worker-alpha"
4527
4528 def test_enqueue_then_list_shows_created_by(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4529 repo = _make_repo(tmp_path)
4530 eq_args = _enqueue_ns(title="listed-task", queue="default", run_id="enqueuer-1")
4531 with _patch_repo(repo):
4532 run_enqueue(eq_args)
4533 capsys.readouterr()
4534
4535 args = _tasks_ns()
4536 with _patch_repo(repo):
4537 run_tasks(args)
4538 out = json.loads(capsys.readouterr().out)
4539 assert len(out["items"]) == 1
4540 assert out["items"][0]["created_by"] == "enqueuer-1"
4541
4542 def test_limit_with_filter_shows_highest_priority(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4543 repo = _make_repo(tmp_path)
4544 with _freeze(_EPOCH):
4545 for i in range(10):
4546 create_task(repo, f"task-{i}", queue="q", priority=i)
4547 args = _tasks_ns(queue="q", limit=3)
4548 with _patch_repo(repo):
4549 run_tasks(args)
4550 out = json.loads(capsys.readouterr().out)
4551 assert len(out["items"]) == 3
4552 # Top 3 priorities should be 9, 8, 7
4553 assert [i["priority"] for i in out["items"]] == [9, 8, 7]
4554
4555
4556 class TestTasksStress:
4557 """Performance and concurrency tests for run_tasks."""
4558
4559 def test_500_tasks_listed_under_5s(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4560 repo = _make_repo(tmp_path)
4561 for i in range(500):
4562 create_task(repo, f"task-{i}", queue="default")
4563 args = _tasks_ns(limit=500)
4564 start = time.monotonic()
4565 with _patch_repo(repo):
4566 run_tasks(args)
4567 elapsed = time.monotonic() - start
4568 out = json.loads(capsys.readouterr().out)
4569 assert out["total"] == 500
4570 assert elapsed < 5.0, f"listing 500 tasks took {elapsed:.1f}s"
4571
4572 def test_500_tasks_with_filter_under_5s(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4573 repo = _make_repo(tmp_path)
4574 for i in range(250):
4575 create_task(repo, f"billing-{i}", queue="billing")
4576 for i in range(250):
4577 create_task(repo, f"infra-{i}", queue="infra")
4578 args = _tasks_ns(queue="billing", limit=250)
4579 start = time.monotonic()
4580 with _patch_repo(repo):
4581 run_tasks(args)
4582 elapsed = time.monotonic() - start
4583 out = json.loads(capsys.readouterr().out)
4584 assert len(out["items"]) == 250
4585 assert elapsed < 5.0, f"filtered listing took {elapsed:.1f}s"
4586
4587 def test_concurrent_reads_are_safe(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4588 """Concurrent run_tasks calls against the same repo must not crash."""
4589 import concurrent.futures
4590 repo = _make_repo(tmp_path)
4591 for i in range(50):
4592 create_task(repo, f"task-{i}")
4593 errors: list[str] = []
4594
4595 def read_tasks() -> None:
4596 args = _tasks_ns(limit=50)
4597 try:
4598 with _patch_repo(repo):
4599 run_tasks(args)
4600 except Exception as exc: # noqa: BLE001
4601 errors.append(str(exc))
4602
4603 with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
4604 list(pool.map(lambda _: read_tasks(), range(20)))
4605
4606 assert not errors, f"concurrent read errors: {errors}"
4607
4608 def test_no_double_load_with_filter(self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]) -> None:
4609 """Verify counts are still correct when filter is active (no re-load bug)."""
4610 repo = _make_repo(tmp_path)
4611 for i in range(20):
4612 create_task(repo, f"billing-{i}", queue="billing")
4613 for i in range(10):
4614 create_task(repo, f"infra-{i}", queue="infra")
4615
4616 args = _tasks_ns(queue="billing")
4617 with _patch_repo(repo):
4618 run_tasks(args)
4619 out = json.loads(capsys.readouterr().out)
4620 # total must reflect ALL 30 tasks, not just the 20 in billing
4621 assert out["total"] == 30
4622 assert len(out["items"]) == 20
4623
4624
4625 # ── register_all integration ───────────────────────────────────────────────────
4626
4627
4628 class TestRegisterAll:
4629 """register_all attaches all six subcommands to the given subparsers."""
4630
4631 def test_all_commands_registered(self) -> None:
4632 import argparse
4633 parser = argparse.ArgumentParser()
4634 subs = parser.add_subparsers(dest="cmd")
4635 register_all(subs)
4636 # Verify each expected command is parseable
4637 for cmd in ("enqueue", "claim", "complete", "fail-task", "cancel-task", "tasks"):
4638 # A subparser was registered for this command name
4639 # (ArgumentParser stores choices in _subparsers._group_actions)
4640 found = False
4641 for action in parser._subparsers._group_actions:
4642 if cmd in action.choices:
4643 found = True
4644 break
4645 assert found, f"Command '{cmd}' not registered"
4646
4647
4648 # ── Content-addressed task_id ─────────────────────────────────────────────────
4649
4650
4651 class TestTaskIdContentAddressed:
4652 """task_id must be sha256: of genesis content, not a random UUID."""
4653
4654 def test_task_id_is_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
4655 from muse.core.task_queue import compute_task_id
4656 tid = compute_task_id(
4657 title="render stems",
4658 queue="audio",
4659 payload={"track": 1},
4660 priority=5,
4661 created_by="agent-x",
4662 )
4663 assert tid.startswith("sha256:"), f"Expected sha256: prefix, got {tid!r}"
4664 assert len(tid) == 71
4665
4666 def test_task_id_not_uuid(self, tmp_path: pathlib.Path) -> None:
4667 import re
4668 from muse.core.task_queue import compute_task_id
4669 tid = compute_task_id("render stems", "audio", {}, 0, "agent-x")
4670 uuid_re = re.compile(
4671 r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
4672 )
4673 assert not uuid_re.match(tid)
4674
4675 def test_task_id_deterministic(self, tmp_path: pathlib.Path) -> None:
4676 from muse.core.task_queue import compute_task_id
4677 t1 = compute_task_id("render stems", "audio", {"track": 1}, 5, "agent-x")
4678 t2 = compute_task_id("render stems", "audio", {"track": 1}, 5, "agent-x")
4679 assert t1 == t2
4680
4681 def test_task_id_differs_by_title(self, tmp_path: pathlib.Path) -> None:
4682 from muse.core.task_queue import compute_task_id
4683 t1 = compute_task_id("render stems", "audio", {}, 0, "agent-x")
4684 t2 = compute_task_id("export wav", "audio", {}, 0, "agent-x")
4685 assert t1 != t2
4686
4687 def test_task_id_differs_by_queue(self, tmp_path: pathlib.Path) -> None:
4688 from muse.core.task_queue import compute_task_id
4689 t1 = compute_task_id("render stems", "audio", {}, 0, "agent-x")
4690 t2 = compute_task_id("render stems", "midi", {}, 0, "agent-x")
4691 assert t1 != t2
4692
4693 def test_create_task_produces_sha256_id(self, tmp_path: pathlib.Path) -> None:
4694 repo = _make_repo(tmp_path)
4695 task = create_task(repo, "process audio", queue="audio", created_by="agent-x")
4696 assert task.task_id.startswith("sha256:")
4697 assert len(task.task_id) == 71
4698
4699 def test_create_task_id_matches_compute(self, tmp_path: pathlib.Path) -> None:
4700 from muse.core.task_queue import compute_task_id
4701 repo = _make_repo(tmp_path)
4702 task = create_task(
4703 repo,
4704 "process audio",
4705 queue="audio",
4706 payload={"track": 3},
4707 priority=2,
4708 created_by="agent-x",
4709 )
4710 expected = compute_task_id(
4711 title="process audio",
4712 queue="audio",
4713 payload={"track": 3},
4714 priority=2,
4715 created_by="agent-x",
4716 )
4717 assert task.task_id == expected
4718
4719
4720 # ---------------------------------------------------------------------------
4721 # Flag registration
4722 # ---------------------------------------------------------------------------
4723
4724
4725 class TestRegisterFlags:
4726 def _parse_enqueue(self, *args: str):
4727 import argparse
4728 from muse.cli.commands.task_queue import register_enqueue
4729 p = argparse.ArgumentParser()
4730 sub = p.add_subparsers()
4731 register_enqueue(sub)
4732 return p.parse_args(["enqueue", *args])
4733
4734 def test_default_json_out_is_false(self) -> None:
4735 ns = self._parse_enqueue("test-task", "--run-id", "orch")
4736 assert ns.json_out is False
4737
4738 def test_json_flag_sets_json_out(self) -> None:
4739 ns = self._parse_enqueue("test-task", "--run-id", "orch", "--json")
4740 assert ns.json_out is True
4741
4742 def test_j_shorthand_sets_json_out(self) -> None:
4743 ns = self._parse_enqueue("test-task", "--run-id", "orch", "-j")
4744 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago