Files
FabledScribe/src/scribe/services/planning.py
T
bvandeusenandClaude Opus 5 441a1ac31d
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 1m2s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 39s
fix(#4016): records that cite each other are created together, and a guessed id is refused
Sessions predicted the ids their next creates would get and wrote them into
plan bodies and reference notes before the records existed. The database
never collides; the sequence is shared by every session and user, so any
concurrent create took the guessed numbers and the references pointed at
someone else's records.

- create_records (new MCP tool) and start_planning(body=, steps=) create
  their records in ONE transaction: insert, flush for the real ids, rewrite
  {{ref:N}} / {{ref:milestone}} placeholders as #id "title", commit. No
  prediction, no waiting, no stub records left behind when a batch fails.
  Ids need not be consecutive and nothing depends on it.
- Every MCP create/update of a note, task or milestone refuses a #N sitting
  just above the highest assigned id (within 50): that can only be a guess.
  Refusal, not warning. Numbers far above the max (PRs, forge issues) pass.
- notes.build_note splits validation out of create_note so the batch
  validates records exactly as a single create does.
- writing-plans and using-scribe say to pass steps up front and never write
  an unassigned id; plugin version minted.

Integration test runs six concurrent batches and checks each resolves its
placeholders to its own records, and that a failing batch writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:33:56 -04:00

101 lines
3.6 KiB
Python

"""Planning service — start_planning creates a MILESTONE seeded as the plan
container, surfacing the project's applicable Rulebook rules at the planning
moment so rules land before any work.
The milestone IS the plan: its `body` holds the design/intent (Goal/Approach/
Verification), and the individual steps live as first-class child tasks
(milestone_id) rather than checkboxes crammed into one body. The legacy
kind=plan task is retired going forward — start_planning never creates one.
Steps passed up front are created in the SAME transaction as the milestone
(services/record_batch.py), so the plan body and the steps can cite each other
through `{{ref:N}}` placeholders instead of predicted ids (#4016).
"""
from __future__ import annotations
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import record_batch as batch_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.record_refs import placeholder_keys
# The plan body template — design only. Steps are NOT checkboxes here; each
# step becomes its own child task under this milestone (status, work-logs,
# priority of its own).
PLAN_TEMPLATE = """## Goal
## Approach
## Verification
"""
async def start_planning(
user_id: int,
project_id: int,
title: str,
body: str | None = None,
steps: list[batch_svc.BatchItem] | None = None,
) -> dict:
"""Create a milestone seeded as a plan container and return it with the
project's applicable rules + brief context.
`body` replaces the seeded template; `steps` creates the step-tasks with
the milestone, atomically.
Returns:
{
"milestone": <milestone dict>,
"applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool,
"project_goal": str,
"open_task_count": int,
"steps": [<task dict>, ...], # only when steps were given
}
"""
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
plan_body = body or PLAN_TEMPLATE
step_notes = []
if steps:
milestone, step_notes = await batch_svc.create_batch(
user_id, steps, project_id=project_id,
milestone=batch_svc.BatchMilestone(title=title, body=plan_body),
)
else:
# A placeholder with no batch behind it has nothing to resolve to, and
# storing `{{ref:1}}` verbatim would be a reference to nothing.
if placeholder_keys(plan_body):
raise ValueError(
"the plan body uses {{ref:...}} placeholders but no steps were "
"given — pass steps=[...] so they have records to name"
)
milestone = await milestones_svc.create_milestone(
user_id,
project_id=project_id,
title=title,
body=plan_body,
status="active",
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=user_id,
)
_, open_count = await notes_svc.list_notes(
user_id, is_task=True, status="todo", project_id=project_id, limit=1,
)
result = {
"milestone": milestone.to_dict(),
**rulebooks_svc.rules_payload(applicable, user_id=user_id, source="start_planning"),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
if steps:
result["steps"] = [n.to_dict() for n in step_notes]
return result