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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user