dev → main: task claims (milestone 381), embedding model stamp, dedup copy band, sweep-shared.css #185
@@ -0,0 +1,38 @@
|
||||
"""task_claims — a task can say a session is working it (milestone 381 step 2)
|
||||
|
||||
Revision ID: 0110
|
||||
Revises: 0109
|
||||
Create Date: 2026-09-23
|
||||
|
||||
`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 rather than as a flag, so it can be read as dead without anything having
|
||||
cleared it (`services/task_claims.py` has the semantics).
|
||||
|
||||
Four nullable columns, no backfill: no existing row has ever been claimed, and
|
||||
inventing a claim for one would assert attention nobody observed. No CHECK
|
||||
enum is touched.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0110"
|
||||
down_revision = "0109"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column(
|
||||
"claimed_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
))
|
||||
op.add_column("notes", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("notes", sa.Column("claim_touched_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("notes", sa.Column("claim_session", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "claim_session")
|
||||
op.drop_column("notes", "claim_touched_at")
|
||||
op.drop_column("notes", "claimed_at")
|
||||
op.drop_column("notes", "claimed_by")
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||
"version": "2026.09.23.2127",
|
||||
"version": "2026.09.23.2309",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
@@ -71,6 +71,15 @@
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_outcome.sh\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*__(update_task|add_task_log)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_claim_session.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe — say WHICH session holds a task's claim (milestone 381 step 2).
|
||||
#
|
||||
# The server stamps a claim on the write that is the work — a task reaching
|
||||
# `in_progress`, a work log on an open task — so every MCP client gets a claim
|
||||
# that dies on its own once its lease runs out. What the server cannot know is
|
||||
# which SESSION made the write: an MCP caller is a user and nothing more.
|
||||
#
|
||||
# The harness knows. This PostToolUse hook watches `update_task` and
|
||||
# `add_task_log` and reports the event's `session_id` against the task the
|
||||
# call named, so the claim can say "this session" rather than "someone,
|
||||
# recently". Same evidence class as scribe_record_opened.sh: a tool call
|
||||
# happened and the harness reported it; nothing here asks the model anything.
|
||||
#
|
||||
# It cannot CREATE a claim. The server binds the session only to a live claim
|
||||
# the caller already holds, so a call that closed the task, or one on a task
|
||||
# someone else is working, binds nothing.
|
||||
#
|
||||
# EXIT 0 AND SILENT, ALWAYS. This decorates a record; a PostToolUse hook that
|
||||
# spoke would put a line after every task write, and a failure here must never
|
||||
# turn a successful tool call into a hook error.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
scribe_config || exit 0
|
||||
|
||||
event=$(cat 2>/dev/null || true)
|
||||
[ -n "$event" ] || exit 0
|
||||
|
||||
event_flat=$(printf '%s' "$event" | scribe_json_flat)
|
||||
session_id=$(scribe_json_pick "$event_flat" '.session_id')
|
||||
[ -n "$session_id" ] || exit 0
|
||||
|
||||
task_id=$(scribe_json_pick "$event_flat" '.tool_input.task_id')
|
||||
task_id=$(printf '%s' "$task_id" | tr -cd '0-9')
|
||||
[ -n "$task_id" ] || exit 0
|
||||
|
||||
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
|
||||
curl -fsS --max-time 4 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/claim-session?task_id=${task_id}&session_id=${sid_enc}" \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
@@ -386,6 +386,15 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
"tool_input": {"rule_id": 1, "outcome": "applied"},
|
||||
"tool_response": {}}
|
||||
),
|
||||
# The claim-session binder (milestone 381). Silent like the ledgers above —
|
||||
# a PostToolUse hook that spoke would add a line after every task write —
|
||||
# and with no instance configured it must exit before reaching for one.
|
||||
"scribe_claim_session.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".",
|
||||
"tool_name": "mcp__scribe__add_task_log",
|
||||
"tool_input": {"task_id": 1, "content": "smoke"},
|
||||
"tool_response": {}}
|
||||
),
|
||||
# The shared library is sourced, never run; executed bare it defines
|
||||
# functions and exits — silent by construction.
|
||||
"scribe_defs.sh": "",
|
||||
|
||||
@@ -76,6 +76,16 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# The claim — which session is working this task NOW (milestone 381).
|
||||
# Orthogonal to `status`: status is the work's state and durable, the claim
|
||||
# is a session's attention and dies on read once `claim_touched_at` is past
|
||||
# the lease. Semantics in services/task_claims.py.
|
||||
claimed_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
claim_touched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
claim_session: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# WHAT KIND of record this is, on the note/entity axis. Task-ness is tracked
|
||||
# by `status`, not here (person/place/list entity types removed 2026-07):
|
||||
# note (default) — authored prose, findable by what it is ABOUT
|
||||
@@ -184,6 +194,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"completed_at": iso(self.completed_at),
|
||||
"recurrence_rule": self.recurrence_rule,
|
||||
"recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
|
||||
"claim": _claim_state(self),
|
||||
"is_task": self.is_task,
|
||||
"note_type": self.note_type or "note",
|
||||
"task_kind": self.task_kind,
|
||||
@@ -198,3 +209,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _claim_state(note: "Note") -> dict | None:
|
||||
from scribe.services.task_claims import claim_state
|
||||
|
||||
return claim_state(note)
|
||||
|
||||
@@ -15,6 +15,7 @@ from scribe.config import Config
|
||||
from scribe.services import plugin_context as plugin_ctx_svc
|
||||
from scribe.services import repo_bindings as repo_bindings_svc
|
||||
from scribe.services import report_check as report_check_svc
|
||||
from scribe.services import task_claims as task_claims_svc
|
||||
from scribe.services.settings import get_admin_setting, set_setting
|
||||
|
||||
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
|
||||
@@ -352,6 +353,35 @@ async def report_check():
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@plugin_bp.get("/claim-session")
|
||||
@login_required
|
||||
async def claim_session():
|
||||
"""Bind the harness's session id to the caller's live claim on a task (milestone 381).
|
||||
|
||||
Called by `scribe_claim_session.sh` after `update_task` / `add_task_log`.
|
||||
The server has already stamped the claim on that write; this only says
|
||||
WHICH session made it — an id the harness reported, not one the model
|
||||
asserted. A GET for the reason every plugin endpoint is one: a read-scoped
|
||||
key must be enough to run the plugin.
|
||||
|
||||
Query:
|
||||
task_id (int) — the task the tool call named.
|
||||
session_id (str) — the Claude Code session id from the hook event.
|
||||
|
||||
Returns the claim when one was bound, `{"claim": null}` when there was
|
||||
nothing to bind to (no live claim of the caller's, or not writable).
|
||||
"""
|
||||
try:
|
||||
task_id = int(request.args.get("task_id") or "")
|
||||
except ValueError:
|
||||
return jsonify({"error": "task_id must be an integer"}), 400
|
||||
session_id = (request.args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return jsonify({"error": "session_id is required"}), 400
|
||||
claim = await task_claims_svc.bind_session(g.user.id, task_id, session_id)
|
||||
return jsonify({"claim": claim})
|
||||
|
||||
|
||||
@plugin_bp.get("/processes")
|
||||
@login_required
|
||||
async def process_manifest():
|
||||
|
||||
@@ -308,23 +308,32 @@ def build_note(
|
||||
# (#3683) — otherwise `create_task(status="in_progress")` writes a row no
|
||||
# update could produce: started, with no `started_at`.
|
||||
if status is not None:
|
||||
apply_status_transition(note)
|
||||
apply_status_transition(note, user_id)
|
||||
return note
|
||||
|
||||
|
||||
def apply_status_transition(note: Note) -> None:
|
||||
def apply_status_transition(note: Note, user_id: int | None = None) -> None:
|
||||
"""Stamp what reaching `note.status` implies — the ONE statement of it.
|
||||
|
||||
Called by the update path whenever `status` is written and by `build_note`
|
||||
whenever a create names one, so a task created at a status is
|
||||
indistinguishable from one that reached it by update. Two copies of "what
|
||||
a status implies" are where the two drift, and #3683 was that drift.
|
||||
|
||||
`user_id` is whoever is making the change: reaching `in_progress` is the
|
||||
moment a session takes the work on, so it stamps that user's claim, and a
|
||||
status that ends or un-starts the work releases it (milestone 381).
|
||||
"""
|
||||
from scribe.services.task_claims import release_claim, stamp_claim
|
||||
|
||||
_now = datetime.now(timezone.utc)
|
||||
if note.status == TaskStatus.in_progress.value:
|
||||
if note.started_at is None:
|
||||
note.started_at = _now
|
||||
if user_id is not None:
|
||||
stamp_claim(note, user_id, _now)
|
||||
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
|
||||
release_claim(note)
|
||||
note.completed_at = _now
|
||||
if note.recurrence_rule:
|
||||
from scribe.services.recurrence import calculate_next_due
|
||||
@@ -334,6 +343,7 @@ def apply_status_transition(note: Note) -> None:
|
||||
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
|
||||
)
|
||||
elif note.status == TaskStatus.todo.value:
|
||||
release_claim(note)
|
||||
note.started_at = None
|
||||
note.completed_at = None
|
||||
note.recurrence_next_spawn_at = None
|
||||
@@ -687,7 +697,7 @@ async def update_note(
|
||||
if recompose is not None:
|
||||
note.data = recompose(note)
|
||||
if "status" in fields:
|
||||
apply_status_transition(note)
|
||||
apply_status_transition(note, user_id)
|
||||
note.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""A task's claim — which session is working it right now (milestone 381 step 2).
|
||||
|
||||
`status` says where THE WORK stands and is durable: `in_progress` means
|
||||
committed to, not finished. It cannot also say "someone is on this now",
|
||||
because nothing ever clears it — a session that crashes, is killed or simply
|
||||
moves on leaves `in_progress` behind, and the row goes on asserting attention
|
||||
nobody is paying. The claim is the other half, a property of a session's
|
||||
attention rather than of the work, and it is built so that nothing has to
|
||||
clear it either.
|
||||
|
||||
WHO STAMPS IT. The server, on the write that IS the work: a transition to
|
||||
`in_progress` and a work log on an open task. No tool asks the model to claim
|
||||
anything, because a claim the model has to remember is the flag this replaces.
|
||||
Any MCP client gets the lease; the Claude Code plugin's PostToolUse hook then
|
||||
binds the harness's session id to it (`bind_session`), so the claim can say
|
||||
WHICH session and not only "someone, recently" — the harness reports the id,
|
||||
the model asserts nothing.
|
||||
|
||||
HOW IT DIES. On read. A claim is live while its last touch is inside
|
||||
`CLAIM_LEASE`; past that it reads as dead, whatever the row still holds. There
|
||||
is no sweep: a job that tidies claims would reintroduce exactly the dependency
|
||||
on something running that this is designed out of. A status that ends or
|
||||
un-starts the work (done, cancelled, todo) releases it outright.
|
||||
|
||||
`in_progress` with no live claim is the state this exists to make sayable:
|
||||
committed to, and nobody on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scribe.models.note import Note
|
||||
|
||||
# How long a claim stays live after its last touch. Long enough that a
|
||||
# compaction, a resume or a long read does not kill it; short enough that a
|
||||
# session gone overnight reads as gone. The cost either way is stated rather
|
||||
# than hidden: readers show the age beside `live`, never the boolean alone.
|
||||
CLAIM_LEASE = timedelta(hours=2)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def claim_is_live(note: Note, now: datetime | None = None) -> bool:
|
||||
touched = note.claim_touched_at
|
||||
return touched is not None and (now or _now()) - touched < CLAIM_LEASE
|
||||
|
||||
|
||||
def stamp_claim(note: Note, user_id: int, now: datetime | None = None) -> None:
|
||||
"""Record that `user_id` is working this task now.
|
||||
|
||||
The most recent worker holds it: a live claim by someone else is taken
|
||||
over rather than refused, because the write that stamps a claim has already
|
||||
happened — it is the evidence of who is on the task, and refusing the claim
|
||||
would only make the record less true. `claimed_at` restarts whenever the
|
||||
holder changes or the previous claim had died, so "since" means since THIS
|
||||
stretch of attention, not since the task was first touched.
|
||||
"""
|
||||
now = now or _now()
|
||||
if note.claimed_by != user_id or not claim_is_live(note, now):
|
||||
note.claimed_by = user_id
|
||||
note.claimed_at = now
|
||||
note.claim_session = None
|
||||
note.claim_touched_at = now
|
||||
|
||||
|
||||
def release_claim(note: Note) -> None:
|
||||
"""Clear the claim. Idempotent — releasing nothing is not an error."""
|
||||
note.claimed_by = None
|
||||
note.claimed_at = None
|
||||
note.claim_touched_at = None
|
||||
note.claim_session = None
|
||||
|
||||
|
||||
def claim_state(note: Note, now: datetime | None = None) -> dict | None:
|
||||
"""The claim as a reader sees it, or None when there has never been one.
|
||||
|
||||
A dead claim is returned, not hidden: "last worked by that session three
|
||||
days ago" is what a resuming session needs to know, and `live: false` says
|
||||
no-one should read it as current.
|
||||
"""
|
||||
if note.claimed_at is None:
|
||||
return None
|
||||
from scribe.models.base import iso
|
||||
|
||||
return {
|
||||
"held_by": note.claimed_by,
|
||||
"session": note.claim_session,
|
||||
"since": iso(note.claimed_at),
|
||||
"touched": iso(note.claim_touched_at),
|
||||
"live": claim_is_live(note, now),
|
||||
}
|
||||
|
||||
|
||||
async def bind_session(user_id: int, task_id: int, session_id: str) -> dict | None:
|
||||
"""Attach a harness-reported session id to the caller's live claim.
|
||||
|
||||
Called by the plugin's PostToolUse hook after `update_task` or
|
||||
`add_task_log`. A no-op — returning None — when the task is not writable
|
||||
by the caller, has no live claim, or the live claim is someone else's: the
|
||||
hook reports what happened, it cannot create a claim the server did not
|
||||
stamp. A different session taking over a live claim restarts `since`,
|
||||
for the reason `stamp_claim` gives.
|
||||
"""
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.services.access import can_write_note
|
||||
|
||||
session_id = (session_id or "").strip()[:200]
|
||||
if not session_id or not await can_write_note(user_id, task_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, task_id)
|
||||
if note is None or note.claimed_by != user_id or not claim_is_live(note):
|
||||
return None
|
||||
now = _now()
|
||||
if note.claim_session not in (None, session_id):
|
||||
note.claimed_at = now
|
||||
note.claim_session = session_id
|
||||
note.claim_touched_at = now
|
||||
await session.commit()
|
||||
return claim_state(note, now)
|
||||
@@ -6,8 +6,9 @@ from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note import Note, TaskStatus
|
||||
from scribe.services.access import can_read_note, readable_notes_clause
|
||||
from scribe.services.task_claims import stamp_claim
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,8 +56,14 @@ async def create_log(
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == task_id, Note.user_id == user_id)
|
||||
)
|
||||
if result.scalars().first() is None:
|
||||
task = result.scalars().first()
|
||||
if task is None:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
# Logging IS working the task, so it stamps the claim (milestone 381) —
|
||||
# unless the work is over: a retrospective note on a closed task is not
|
||||
# a session picking it up.
|
||||
if task.status not in (TaskStatus.done.value, TaskStatus.cancelled.value):
|
||||
stamp_claim(task, user_id)
|
||||
log = TaskLog(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -183,6 +183,9 @@ def fake_note(**attrs) -> MagicMock:
|
||||
# Milestone 317: a truthy mock here reads as "this note carries a
|
||||
# check", which trips the guard on records that may not have one.
|
||||
"verify_with": None, "expires_when": None, "verified_at": None,
|
||||
# Milestone 381: a truthy mock would read as a live claim.
|
||||
"claimed_by": None, "claimed_at": None, "claim_touched_at": None,
|
||||
"claim_session": None,
|
||||
}, attrs)
|
||||
|
||||
|
||||
@@ -193,6 +196,9 @@ def fake_task(**attrs) -> MagicMock:
|
||||
"tags": [], "parent_id": None, "project_id": None, "is_task": True,
|
||||
"task_kind": "work", "user_id": 7, "deleted_at": None,
|
||||
"verify_with": None, "expires_when": None, "verified_at": None,
|
||||
# Milestone 381: a truthy mock would read as a live claim.
|
||||
"claimed_by": None, "claimed_at": None, "claim_touched_at": None,
|
||||
"claim_session": None,
|
||||
}, attrs)
|
||||
|
||||
|
||||
|
||||
@@ -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