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

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:
2026-09-23 19:10:06 -04:00
co-authored by Claude Opus 5.5
parent b8f543f45a
commit b06b3a1d8a
12 changed files with 473 additions and 6 deletions
+170
View File
@@ -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