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