feat(tasks): SessionStart reads the claim — a compaction gets its work back (milestone 381 step 3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Canceled after 45s

The claim now pays rent to the session that set it. The SessionStart hook
sends the host's `source` and the session id; the server renders a claim
section by source (task_claims.render_claims):

- compact / clear: the work this session had claimed, each with its two
  latest log entries, so a compacted session resumes from the record
  rather than from a count of open tasks.
- startup / clear: other sessions' live claims (may still be running) and
  in-progress tasks whose claim went quiet (abandoned mid-task).
- fork: the same, framed as "two sessions may now hold this".
- resume, or no source sent: nothing.

The task sidebar shows a live claim as "Being worked" and a dead one on
open work as "Went quiet", so the operator sees a session die mid-task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 06:41:19 -04:00
co-authored by Claude Opus 5.5
parent a585fe0be0
commit e46eea3b52
8 changed files with 313 additions and 7 deletions
+12
View File
@@ -29,6 +29,10 @@ export interface Note {
due_date: string | null;
started_at: string | null;
completed_at: string | null;
// Which session is working this task now (milestone 381). Null when no
// session ever claimed it; `live` false when the claim's lease ran out —
// a session that went quiet mid-task, which is worth seeing, not hiding.
claim?: TaskClaim | null;
recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null;
is_task: boolean;
@@ -53,3 +57,11 @@ export interface NoteListResponse {
notes: Note[];
total: number;
}
export interface TaskClaim {
held_by: number | null;
session: string | null;
since: string | null;
touched: string | null;
live: boolean;
}
+19 -2
View File
@@ -15,7 +15,8 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
import type { TaskKind } from "@/types/note";
import { useSystemsStore } from "@/stores/systems";
import type { System } from "@/api/systems";
import type { Note } from "@/types/note";
import type { Note, TaskClaim } from "@/types/note";
import { relativeTime } from "@/composables/useRelativeTime";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -54,6 +55,7 @@ const parentId = ref<number | null>(null);
const parentTitle = ref("");
const startedAt = ref<string | null>(null);
const completedAt = ref<string | null>(null);
const claim = ref<TaskClaim | null>(null);
const recurrenceRule = ref<Record<string, unknown> | null>(null);
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
@@ -318,6 +320,7 @@ onMounted(async () => {
const noteTask = store.currentTask as unknown as Note;
startedAt.value = noteTask.started_at ?? null;
completedAt.value = noteTask.completed_at ?? null;
claim.value = noteTask.claim ?? null;
recurrenceRule.value = noteTask.recurrence_rule ?? null;
savedTitle = title.value;
savedBody = body.value;
@@ -592,7 +595,21 @@ useEditorGuards(dirty, save);
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
</select>
</div>
<div v-if="startedAt || completedAt" class="sb-timestamps">
<div v-if="startedAt || completedAt || claim" class="sb-timestamps">
<div v-if="claim && claim.live" class="sb-timestamp">
<span class="sb-ts-label">Being worked</span>
<span class="sb-ts-value">
by a session{{ claim.session ? ` (${claim.session.slice(0, 8)})` : "" }},
last active {{ claim.touched ? relativeTime(claim.touched) : "recently" }}
</span>
</div>
<div v-else-if="claim" class="sb-timestamp">
<span class="sb-ts-label">Went quiet</span>
<span class="sb-ts-value">
the session working this stopped
{{ claim.touched ? relativeTime(claim.touched) : "" }} without finishing it
</span>
</div>
<div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span>
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
+1 -1
View File
@@ -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.2309",
"version": "2026.09.24.1041",
"author": {
"name": "Bryan Van Deusen"
},
+8 -2
View File
@@ -187,8 +187,14 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
marker_why=${marker_read#*$'\t'}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
scope=$(scribe_scope_query "$repo_dir")
q=""
[ -n "$scope" ] && q="?${scope}"
# The source and the session id decide what the claim section says
# (milestone 381): a compaction gets back the work this session had claimed,
# with its latest logs; a new session hears about other sessions' claims.
sid_now=$(scribe_json_pick "$event_flat" '.session_id')
q="source=$(printf '%s' "$source" | scribe_urlenc)"
[ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)"
[ -n "$scope" ] && q="${q}&${scope}"
q="?${q}"
# ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … ||
# body=""`, which folded a timeout, an HTTP error and a refused key into one
# sentence — so a session that started blind could not say why, and neither
+7 -1
View File
@@ -70,10 +70,16 @@ async def session_context():
send it when a `.scribe` marker file names a project. Takes
precedence over `repo`. Access-checked like any other read — an id
this account cannot read loads no project rather than failing.
source (optional str) — the host's SessionStart source (startup,
resume, compact, clear, fork); decides what the claim section says.
session_id (optional str) — the session's id, so a claim bound to it
reads as this session's own (milestone 381).
"""
project_id, _repo, unbound_repo = await _project_scope()
result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo
g.user.id, project_id, unbound_repo=unbound_repo,
source=(request.args.get("source") or "").strip()[:20],
session_id=(request.args.get("session_id") or "").strip()[:200],
)
return jsonify(result)
+20 -1
View File
@@ -26,6 +26,7 @@ from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc
from scribe.services import task_claims as task_claims_svc
from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import (
document_title,
@@ -3055,7 +3056,8 @@ def _goal_line(goal: str, project_id: int) -> str:
async def build_session_context(
user_id: int, project_id: int = 0, unbound_repo: str = ""
user_id: int, project_id: int = 0, unbound_repo: str = "",
source: str = "", session_id: str = "",
) -> dict:
"""Render the SessionStart context for a user, optionally project-scoped.
@@ -3069,6 +3071,12 @@ async def build_session_context(
unbound_repo: when the hook sent a repo remote that maps to no project,
its normalized key — triggers a one-line "bind this repo" hint so
the binding is self-healing.
source / session_id: the host's SessionStart `source` and the
session's id, when the adapter sends them. They decide what the
claim section says (milestone 381 step 3, `task_claims.
render_claims`): a compaction gets back the work it had claimed
with its latest logs; a new session hears about other sessions'
live and abandoned claims; a resume hears nothing.
Returns {"context": str, "project": dict | None}.
@@ -3135,6 +3143,17 @@ async def build_session_context(
f"`get_design_system({design['id']})` → "
f"`resolved_guidance`.",
]
# The claim section goes after the project block and before any "nothing
# loaded" note: claimed work is the most specific thing this session can be
# told, and it is true whether or not a project resolved. Best-effort — a
# session start never fails on it.
try:
lines += await task_claims_svc.claims_for_session_start(
user_id, project_dict["id"] if project_dict else 0, source, session_id,
)
except Exception: # noqa: BLE001 - context is best-effort
logger.warning("claim section skipped", exc_info=True)
# Nothing loaded — say which nothing (#4085). This used to hang off the
# `if project_id:` above as an `elif`, which meant an id that was SENT and
# did not resolve produced no message at all: the outer branch was taken,
+158
View File
@@ -123,3 +123,161 @@ async def bind_session(user_id: int, task_id: int, session_id: str) -> dict | No
note.claim_touched_at = now
await session.commit()
return claim_state(note, now)
# --- The readers (milestone 381 step 3) ---------------------------------------
#
# A claim nobody reads is the state before this milestone. SessionStart is the
# reader that pays rent to the session that set it, and it branches on the
# `source` the host sends, because the same claim means different things
# depending on what just happened to the context:
#
# compact the context was summarised away and the claim is certainly ours.
# Push the claimed work AND its latest log entries — the state a
# compaction destroys, which the record already holds. A count of
# open tasks cannot answer "where were we".
# clear the context was wiped, so the same push; and other sessions' claims
# are worth knowing about, as on a startup.
# startup a new session. Claims held by OTHER sessions are the news: live
# ones may be running right now, dead ones were abandoned mid-task.
# fork the session carries a conversation that held claims under another
# id. Two sessions now believe they hold the same work, so the
# live claims are named as possibly-the-parent's, with what a write
# does about it.
# resume the context was restored intact. Say nothing.
# What a session is told, per source. Pure data so the branch is one lookup.
_PUSH_OWN = {"compact", "clear"}
_NAME_OTHERS = {"startup", "clear", "fork"}
# Caps: a session-start block is read by every session, so it is sized for the
# few claims that matter rather than for the worst case.
_OWN_CAP = 5
_OTHERS_CAP = 5
_LOGS_PER_TASK = 2
_LOG_CHARS = 600
def _age(when: datetime | None, now: datetime) -> str:
if when is None:
return "at an unknown time"
secs = max(0, int((now - when).total_seconds()))
if secs < 90:
return "just now"
if secs < 5400:
return f"{secs // 60}m ago"
if secs < 2 * 86400:
return f"{secs // 3600}h ago"
return f"{secs // 86400}d ago"
def render_claims(
source: str,
session_id: str,
claims: list,
logs: dict[int, list],
now: datetime | None = None,
) -> list[str]:
"""The claim section of the SessionStart context, as markdown lines.
`claims` are the caller's claimed tasks (objects with id, title, status and
the claim columns); `logs` maps a task id to its newest log entries
(objects with `created_at` and `content`), newest first. Empty when there is
nothing this source should say — silence is the right answer on a resume,
and on any start with no claims.
"""
from scribe.services.text import elide
now = now or _now()
source = (source or "").strip()
session_id = (session_id or "").strip()
ours = [c for c in claims if claim_is_live(c, now)
and (c.claim_session == session_id or c.claim_session is None)]
others_live = [c for c in claims if claim_is_live(c, now)
and c.claim_session not in (None, session_id)]
abandoned = [c for c in claims if not claim_is_live(c, now)
and c.status == "in_progress"]
lines: list[str] = []
if source in _PUSH_OWN and session_id and ours:
lines += [
"",
"## In flight — the work this session had claimed",
"Scribe's record of what you were doing before the context was "
"lost. Carry on from here; the full log is `get_task(id)`.",
]
for c in ours[:_OWN_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" ({c.status}) — claimed "
f"{_age(c.claimed_at, now)}, last touched {_age(c.claim_touched_at, now)}"
)
for entry in logs.get(c.id, [])[:_LOGS_PER_TASK]:
text, _ = elide(" ".join((entry.content or "").split()), _LOG_CHARS)
lines.append(f" - log {_age(entry.created_at, now)}: {text}")
if source in _NAME_OTHERS and (others_live or abandoned):
lines += ["", "## Work other sessions were doing"]
if source == "fork":
lines.append(
"This session was forked, so a live claim below may be the "
"session you were forked from — two sessions now think they "
"hold it. Your next log or status change on a task moves its "
"claim here; leave it alone if the other session is still on it."
)
for c in others_live[:_OTHERS_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" — claimed by another session, last "
f"touched {_age(c.claim_touched_at, now)}. It may still be "
"running; check before working the same task."
)
for c in abandoned[:_OTHERS_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" — in progress, but the session "
f"working it went quiet {_age(c.claim_touched_at, now)} without "
"finishing. Read its log and continue it, or set it back to todo."
)
return lines
async def claims_for_session_start(
user_id: int, project_id: int, source: str, session_id: str,
) -> list[str]:
"""Load the caller's claims (in the active project, when one resolved) and
their newest logs, and render them for this `source`.
"The caller's claims" is `claimed_by == user_id` — a statement about whose
attention a claim records, not an access filter: a claim is only ever
stamped by a write the caller was already allowed to make.
"""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.task_log import TaskLog
# No source means the caller did not ask — another client, or an adapter
# older than this section — and a resume restored everything already.
if (source or "") in ("", "resume"):
return []
async with async_session() as session:
q = select(Note).where(
Note.claimed_by == user_id,
Note.claimed_at.is_not(None),
Note.deleted_at.is_(None),
)
if project_id:
q = q.where(Note.project_id == project_id)
claims = list((await session.execute(
q.order_by(Note.claim_touched_at.desc()).limit(_OWN_CAP + 2 * _OTHERS_CAP)
)).scalars().all())
logs: dict[int, list] = {}
if claims:
rows = (await session.execute(
select(TaskLog)
.where(TaskLog.task_id.in_([c.id for c in claims]))
.order_by(TaskLog.created_at.desc())
)).scalars().all()
for row in rows:
bucket = logs.setdefault(row.task_id, [])
if len(bucket) < _LOGS_PER_TASK:
bucket.append(row)
return render_claims(source, session_id, claims, logs)
+88
View File
@@ -168,3 +168,91 @@ async def test_a_closed_task_binds_nothing(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
# --- step 3: the readers ----------------------------------------------------
from types import SimpleNamespace # noqa: E402
_NOW = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc)
def _claimed(id, session, touched_ago, status="in_progress", title=None):
t = _NOW - touched_ago
return SimpleNamespace(
id=id, title=title or f"task {id}", status=status,
claimed_by=1, claimed_at=t, claim_touched_at=t, claim_session=session,
)
def _log(content, ago=timedelta(minutes=5)):
return SimpleNamespace(content=content, created_at=_NOW - ago)
def _render(source, claims, logs=None, sid="me"):
return "\n".join(tc.render_claims(source, sid, claims, logs or {}, now=_NOW))
def test_a_compaction_gets_back_its_own_claimed_work_and_latest_logs():
"""The measurement the milestone names: a compacted session comes back
holding its own state, without being told to go looking."""
mine = _claimed(10, "me", timedelta(minutes=3), title="wire the reader")
out = _render("compact", [mine], {10: [_log("ruled out the cache theory")]})
assert "#10" in out and "wire the reader" in out
assert "ruled out the cache theory" in out
def test_a_resume_says_nothing():
mine = _claimed(10, "me", timedelta(minutes=3))
assert _render("resume", [mine]) == ""
def test_a_startup_names_other_sessions_live_and_abandoned_claims():
live = _claimed(11, "other", timedelta(minutes=10))
gone = _claimed(12, "older", timedelta(days=3))
out = _render("startup", [live, gone])
assert "#11" in out and "may still be running" in out
assert "#12" in out and "went quiet" in out
def test_a_startup_does_not_push_this_sessions_own_work():
"""A new session id owns nothing yet; the own-work push is for a context
that was lost, not one that never existed."""
mine = _claimed(10, "me", timedelta(minutes=3))
assert "In flight" not in _render("startup", [mine])
def test_a_fork_is_told_two_sessions_may_hold_the_same_claim():
parent = _claimed(13, "parent", timedelta(minutes=2))
out = _render("fork", [parent], sid="child")
assert "forked" in out and "#13" in out
def test_a_dead_claim_on_finished_work_is_not_news():
done = _claimed(14, "older", timedelta(days=3), status="done")
assert _render("startup", [done]) == ""
def test_the_session_start_hook_sends_the_source_and_the_session():
"""The reader branches on what the hook sends; a hook that stopped sending
either would leave every session on the no-claims path, silently."""
text = (ROOT / "plugin/hooks/scribe_session_context.sh").read_text()
assert re.search(r'q="source=\$\(printf', text)
assert "session_id=$(printf" in text
@pytest.mark.integration
async def test_session_start_after_a_compaction_carries_the_claimed_task(users):
from scribe.services import task_logs
from scribe.services.plugin_context import build_session_context
owner, _ = users
task = await notes_svc.create_note(owner, title="claimed then compacted",
status="in_progress")
await task_logs.create_log(owner, task.id, "halfway: the migration is written")
await tc.bind_session(owner, task.id, "sess-compact")
ctx = (await build_session_context(
owner, source="compact", session_id="sess-compact"))["context"]
assert "claimed then compacted" in ctx
assert "the migration is written" in ctx