feat(tasks): a task can say a session is working it — the claim (milestone 381 step 2)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m18s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m18s
CI & Build / Build & push image (push) Skipped
status is durable and nothing clears it, so in_progress cannot also mean "someone is on this now". The claim is that second meaning, stored as who and when so it dies on read rather than needing to be cleared. - notes.claimed_by / claimed_at / claim_touched_at / claim_session (0110). - The server stamps it on the write that is the work: reaching in_progress (update or create, via apply_status_transition) and a work log on an open task. done/cancelled/todo release it. Live while touched within CLAIM_LEASE (2h); dead on read past it, with no sweep. - The plugin's PostToolUse hook on update_task/add_task_log binds the harness's session_id (GET /api/plugin/claim-session). It binds only to a live claim the caller holds; it cannot create one. - to_dict carries `claim`. Plugin version minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user