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