Files
FabledScribe/src/scribe/services/milestones.py
T
bvandeusenandClaude Opus 5 5c6175ad97
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
feat(placement): a record you only cite carries its status (#4154)
Step 1 made placement cheap for a task whose status CHANGES: create_task
and update_task return where it sits, and the report is written from
that. It did nothing for a task a reply merely cites.

This milestone's own step-6 review reported "#4014 is the open step of
milestone 409". #4014 had been done for four days; the open step was
#4015. The id did not come from a read — it came from a retrieval hint,
which carries an id, a kind and a title and says nothing about status,
while list_milestones said "8 of 9" and would not say which one. The
gap was there to be filled and the nearest-looking id filled it.

Two surfaces, one principle: the status arrives with the id.

1. get_project_milestone_summaries gains next_step — the earliest open
   step, {id, title, status} or None — carried through _BRIEF_FIELDS to
   enter_project, get_project and list_milestones. One extra flat query
   for the whole batch, so #2384's fan-out does not come back.

   OPEN_STEP_STATUSES moves to services/milestones.py and placement.py
   imports it; both surfaces now answer "what is next" and must not
   drift on what counts as open. Both step queries take the same
   readable_notes_clause (rule 78), so a row cannot name a step its own
   progress numbers exclude.

2. _record_kind renders a task's status: [task (done)], [issue (todo)].
   A finished step and an open one read identically before, which is
   exactly the line the misreport was taken from. Only tasks — is_task
   IS status-is-not-None on the model, so there is no fallback branch.

reporting-back gains the practice, owned and registered in the guidance
ownership table: a record you only mention is a record to read.

The guards are structural and each fails on the regression it names:
the query count is asserted rather than the payload shape, and the two
surfaces' agreement is pinned on the rendered ORDER BY, since a mocked
session hands back whatever order the test chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 12:08:00 -04:00

410 lines
17 KiB
Python

"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.services import access as access_svc
logger = logging.getLogger(__name__)
# The statuses that make a step OPEN work, defined here because this is the
# lower layer: services/placement.py imports it rather than restating it. Two
# surfaces that both answer "what is next" and disagree about what counts as
# next is worse than one of them staying silent.
OPEN_STEP_STATUSES = ("todo", "in_progress")
def embed_milestone(milestone: Milestone) -> None:
"""Refresh a milestone's vectors, fire-and-forget (milestone 415).
At the service, so every path that writes a milestone gets it — the lesson
embed_note records (#2056): a record written through a door that forgot the
call stays out of search until a restart. Exceptions are swallowed because
a milestone that saved must not fail on its index refresh; no running loop
(a script, a unit test) is ordinary. A delete racing the refresh wins: the
upsert claims the milestone's row first (#3262).
"""
try:
import asyncio
from scribe.services.embeddings import upsert_milestone_embedding
asyncio.create_task(upsert_milestone_embedding(
milestone.id, milestone.title, milestone.description, milestone.body,
))
except RuntimeError:
pass
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for milestone %s", milestone.id)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
body: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
body=body,
status=status,
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
embed_milestone(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def titles_for(milestone_ids: set[int]) -> dict[int, str]:
"""{id: title} for the given milestones, for rows that name where a record sits.
No ownership filter, deliberately: the callers are listings whose rows the
caller could already read, and a milestone title is part of "where does
this record sit". Filtering here would blank the placement of a shared
task in someone else's plan while still showing the task.
"""
ids = {i for i in milestone_ids if i}
if not ids:
return {}
async with async_session() as session:
rows = (await session.execute(
select(Milestone.id, Milestone.title).where(
Milestone.id.in_(ids), Milestone.deleted_at.is_(None),
)
)).all()
return {mid: title for mid, title in rows}
async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milestone | None:
"""Fetch a milestone by id within a project, without a user_id ownership check.
Callers must verify project access separately before using this."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
"""Find a milestone by title across ALL projects for this user (case-insensitive)."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).order_by(Milestone.id).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
if {"title", "description", "body"} & set(fields):
embed_milestone(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.milestone_id == milestone_id, Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
# Same rule as the batch path, computed in one place so the two cannot
# drift on the cancelled-exclusion.
return _progress_from_counts(status_counts)
def _progress_from_counts(status_counts: dict[str, int]) -> dict:
"""The progress shape, computed from already-fetched counts.
Split out of get_milestone_progress so the batch path can reuse the rule
rather than restate it — the cancelled-exclusion below is easy to get
subtly different in a second copy, and then two screens disagree about
whether a milestone is finished.
"""
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
# Cancelled tasks are resolved work, not pending — excluded from the
# denominator so a milestone whose only open task was cancelled reaches
# 100% instead of stalling.
active_total = total - cancelled
return {
"total": total,
"completed": completed,
"pct": round(completed / active_total * 100, 1) if active_total > 0 else 0.0,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
async def get_project_milestone_summaries(
user_id: int, project_ids: list[int]
) -> dict[int, list[dict]]:
"""Milestone summaries for MANY projects in three queries total.
The per-project version below is a nested fan-out: one query to list a
project's milestones, then one more per milestone for its progress. Called
for 25 projects concurrently it asked for ~250 pooled connections against a
pool of 15, and every one of them waited out the 30-second checkout timeout
(#2384). This does the same work in a fixed number of queries and one
session: the milestones, their step counts, and their open steps.
Each row carries `next_step` — the earliest open step, or None (#4154).
That is NOT the same question `services/placement.py` answers: placement
knows which step you are on and names the next one AFTER it, while a
listing has no current step, so the earliest open one is the whole answer.
The two share `OPEN_STEP_STATUSES` and the creation ordering so they can
never disagree about which steps are candidates.
"""
if not project_ids:
return {}
async with async_session() as session:
milestones = list((await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id.in_(project_ids),
Milestone.deleted_at.is_(None),
).order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
)).scalars().all())
counts: dict[int, dict[str, int]] = {}
step_touched: dict[int, datetime] = {}
next_step: dict[int, dict] = {}
if milestones:
milestone_ids = [m.id for m in milestones]
# Both step queries below take the SAME visibility clause (rule 78).
# They have to: `next_step` names a step and the counts beside it
# say how many there are, so a row that could name a step its own
# progress excludes would be reporting two different milestones.
readable = access_svc.readable_notes_clause(user_id)
rows = await session.execute(
select(
Note.milestone_id, Note.status, func.count(Note.id),
func.max(Note.updated_at),
)
.where(
Note.milestone_id.in_(milestone_ids),
Note.status.isnot(None),
Note.deleted_at.is_(None),
readable,
)
.group_by(Note.milestone_id, Note.status)
)
for milestone_id, status, count, latest in rows.fetchall():
counts.setdefault(milestone_id, {})[status] = count
if latest and (milestone_id not in step_touched
or latest > step_touched[milestone_id]):
step_touched[milestone_id] = latest
# ONE query for every milestone in the batch, not one per row.
# #2384 was exactly this listing fanned out per milestone, and it
# drained the connection pool; a third flat query keeps the cost
# constant in the number of plans. Ordered the way
# services/placement.py orders steps — creation order, the order a
# plan is written and a batch create inserts — so the first row per
# milestone IS its next open step.
open_rows = await session.execute(
select(Note.milestone_id, Note.id, Note.title, Note.status)
.where(
Note.milestone_id.in_(milestone_ids),
Note.status.in_(OPEN_STEP_STATUSES),
Note.deleted_at.is_(None),
readable,
)
.order_by(Note.created_at.asc(), Note.id.asc())
)
for milestone_id, note_id, title, status in open_rows.fetchall():
next_step.setdefault(
milestone_id, {"id": note_id, "title": title, "status": status},
)
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
for m in milestones:
entry = m.to_dict()
entry.update(_progress_from_counts(counts.get(m.id, {})))
# A milestone's own updated_at doesn't move when its steps do, so a
# plan whose steps closed today would read as untouched since it was
# written (#4045). Touched is the later of the two.
touched = [t for t in (m.updated_at, step_touched.get(m.id)) if t]
entry["last_touched_at"] = max(touched).isoformat() if touched else None
# Always present, None included. "8 of 9 done" tells a reader there is
# an open step and not WHICH, and a gap that shape gets filled from
# whatever id is nearest to hand — a retrieval hint carries an id and a
# title and no status, and that is how a done step was reported as the
# open one (#4154). A listing that names it leaves nothing to guess.
entry["next_step"] = next_step.get(m.id)
out.setdefault(m.project_id, []).append(entry)
return out
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Ordered milestones with progress — the one-project view of
get_project_milestone_summaries (two queries, not N+1)."""
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
# What a milestone LISTING needs: enough to say what each plan is, how far
# along it is, and which step is next. The plan itself (`body`) is
# get_milestone's job. Summaries once carried it, and on a project with 39
# milestones enter_project came to ~222k characters, 168k of them plan bodies. That is past what an MCP client will
# accept as a tool result, so the session handshake arrived as a file to page
# through (#4045). user_id / project_id / timestamps repeat what the caller
# already knows.
_BRIEF_FIELDS = (
"id", "title", "description", "status", "order_index",
"total", "completed", "pct", "status_counts", "next_step",
)
def brief_milestone_summary(
rows: list[dict], *, limit: int | None = None,
) -> tuple[list[dict], int]:
"""Trim summary rows to the listing fields, optionally keeping only the
most recently touched.
With `limit`, keeps the N rows with the latest `last_touched_at`, whatever
their status, most recent first: a handshake says what was worked on
lately, and "active" alone doesn't (a plan can sit active for months).
Without it, every row stays in its original order. Returns (rows, omitted).
"""
kept = rows
if limit is not None:
kept = sorted(
rows, key=lambda r: r.get("last_touched_at") or "", reverse=True,
)[:limit]
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
return brief, len(rows) - len(kept)
def unplanned_milestones(
rows: list[dict], *, exclude_ids: set[int] = frozenset(), limit: int | None = None,
) -> tuple[list[dict], int]:
"""Active milestones with no steps yet, as (rows, omitted).
A plan written as a milestone with a description and no steps is open work
that nothing else names. It is never "touched" — touching is a step
changing — so the recency list that brief_milestone_summary(limit=) builds
can never reach it, and progress reads 0% either way. A project whose
roadmap was written that way ended up with every later plan opened as a
new milestone beside the one that already described it (milestone 415).
`exclude_ids` drops milestones a caller already listed. Kept in roadmap
order (order_index, then creation), the order they were written in.
Rows are id, title and description: what a reader needs to recognise the
plan, and not its body, which get_milestone reads.
"""
found = [
{"id": r["id"], "title": r.get("title"), "description": r.get("description")}
for r in rows
if r.get("status") == "active" and not r.get("total") and r["id"] not in exclude_ids
]
kept = found if limit is None else found[:limit]
return kept, len(found) - len(kept)