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
+13 -3
View File
@@ -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)