"""A task's claim — which session is working it, and how it dies (milestone 381 step 2). WHAT THIS PINS 1. **The write that is the work stamps it.** Reaching `in_progress` — by update OR by a create that names it (#3683) — claims the task for whoever made the change. Nothing asks the model to claim anything. 2. **Ending or un-starting the work releases it**, and releasing is idempotent. 3. **A claim dies on read.** Past the lease it reads `live: false` with nothing having run — no sweep, which is the whole design. 4. **The latest worker holds it**, and `since` restarts when the holder changes; the same holder touching a live claim keeps `since`. 5. **The session is bound, never created.** `bind_session` attaches a harness-reported id only to a live claim the caller already holds. """ import json import re from datetime import datetime, timedelta, timezone from pathlib import Path import pytest import pytest_asyncio from scribe.services import notes as notes_svc from scribe.services import task_claims as tc from tests.helpers import ensure_user ROOT = Path(__file__).resolve().parents[1] def _task(status="todo"): return notes_svc.build_note(1, title="t", status=status) # --- 1. the write that is the work stamps it --------------------------------- def test_a_task_created_in_progress_is_claimed_by_its_creator(): note = notes_svc.build_note(42, title="t", status="in_progress") state = tc.claim_state(note) assert state["held_by"] == 42 and state["live"] is True assert state["session"] is None, "the server never invents a session" def test_reaching_in_progress_by_update_claims_for_the_acting_user(): note = _task() note.status = "in_progress" notes_svc.apply_status_transition(note, user_id=9) assert tc.claim_state(note)["held_by"] == 9 def test_a_task_never_worked_has_no_claim(): assert tc.claim_state(_task()) is None assert _task().to_dict()["claim"] is None # --- 2. ending the work releases it ------------------------------------------ @pytest.mark.parametrize("status", ["done", "cancelled", "todo"]) def test_ending_or_unstarting_the_work_releases_the_claim(status): note = notes_svc.build_note(42, title="t", status="in_progress") note.status = status notes_svc.apply_status_transition(note, user_id=42) assert tc.claim_state(note) is None def test_releasing_nothing_is_not_an_error(): note = _task() tc.release_claim(note) tc.release_claim(note) assert tc.claim_state(note) is None # --- 3. a claim dies on read ------------------------------------------------- def test_a_claim_past_its_lease_reads_dead_with_nothing_having_run(): then = datetime.now(timezone.utc) - tc.CLAIM_LEASE - timedelta(minutes=1) note = _task("in_progress") tc.stamp_claim(note, 42, now=then) state = tc.claim_state(note) assert state["live"] is False assert state["held_by"] == 42, "a dead claim is still reported, not hidden" def test_in_progress_and_unclaimed_is_now_representable(): """The state the milestone exists to make sayable: committed, nobody on it.""" then = datetime.now(timezone.utc) - timedelta(days=3) note = _task("in_progress") tc.stamp_claim(note, 42, now=then) assert note.status == "in_progress" and not tc.claim_is_live(note) # --- 4. the latest worker holds it ------------------------------------------- def test_the_same_holder_touching_a_live_claim_keeps_since(): t0 = datetime.now(timezone.utc) - timedelta(minutes=30) note = _task("in_progress") tc.stamp_claim(note, 42, now=t0) tc.stamp_claim(note, 42) assert note.claimed_at == t0 assert note.claim_touched_at > t0 def test_another_worker_takes_the_claim_over_and_since_restarts(): t0 = datetime.now(timezone.utc) - timedelta(minutes=30) note = _task("in_progress") tc.stamp_claim(note, 42, now=t0) note.claim_session = "a-session" tc.stamp_claim(note, 7) assert note.claimed_by == 7 assert note.claimed_at > t0 assert note.claim_session is None, "the old holder's session is not the new one's" def test_a_dead_claim_restarts_rather_than_resumes(): t0 = datetime.now(timezone.utc) - tc.CLAIM_LEASE - timedelta(hours=1) note = _task("in_progress") tc.stamp_claim(note, 42, now=t0) tc.stamp_claim(note, 42) assert note.claimed_at > t0 # --- the plugin half --------------------------------------------------------- def test_the_binder_hook_watches_the_two_writes_that_stamp_a_claim(): hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"] blocks = [b for b in hooks["PostToolUse"] if any("scribe_claim_session.sh" in h["command"] for h in b["hooks"])] assert len(blocks) == 1 matcher = re.compile(blocks[0]["matcher"]) assert matcher.fullmatch("mcp__plugin_scribe_scribe__update_task") assert matcher.fullmatch("mcp__scribe__add_task_log") assert not matcher.fullmatch("mcp__scribe__get_task") # --- 5. binding a session (integration) -------------------------------------- @pytest_asyncio.fixture async def users(_dispose_engine): from scribe.models import async_session async with async_session() as session: a = (await ensure_user(session, "claims_itest_a")).id b = (await ensure_user(session, "claims_itest_b")).id await session.commit() return a, b @pytest.mark.integration async def test_a_session_binds_to_its_own_live_claim_only(users): owner, stranger = users task = await notes_svc.create_note(owner, title="claim bind", status="in_progress") assert await tc.bind_session(stranger, task.id, "their-session") is None bound = await tc.bind_session(owner, task.id, "sess-1") assert bound["session"] == "sess-1" and bound["live"] is True # A different session taking over a live claim restarts `since`. again = await tc.bind_session(owner, task.id, "sess-2") assert again["session"] == "sess-2" assert again["since"] >= bound["since"] @pytest.mark.integration async def test_a_closed_task_binds_nothing(users): owner, _ = users task = await notes_svc.create_note(owner, title="claim closed", status="in_progress") await notes_svc.update_note(owner, task.id, status="done") assert await tc.bind_session(owner, task.id, "sess") is None