Multi-milestone work and documents on the new loop (M2.4)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s

- Every milestone branch is cut from the latest default branch: the next
  milestone builds on the merged previous one, and a redo after a closed PR
  drops the rejected work. Uncommitted work comes along.
- While the item's PR is open, start_milestone is refused, with the PR's
  state read from Gitea at that moment, not the last watch round.
- finish_milestone(final): earlier PRs say "Part of #N", the last "Closes
  #N"; unsaid, it's final, as before.
- close_item with the item's PR still open waits for the merge (L2: a
  closed item leaves nothing open).
- The driver's instructions cover ARCHITECTURE.md and build plans under
  docs/plans/ (D2); the harness never parses a plan.
- Parking stashes uncommitted work and pushes it to <item>-parked, put back
  on resume, instead of committing it onto the item's branch. The walk
  found a half-done review fix parked onto a PR that had merged meanwhile,
  where it was lost. Architecture D4's parking line says so.

Tests: unit tests for fresh milestone branches, the merge wait (including
a merge not yet watched), redo after a closed PR, Part of/Closes, close
waiting on an open PR, and the parked-fix regression; the walk's new
milestones_start_fresh property (fork-from-current fails 9 seeds); an e2e
two-milestone feature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-05 20:57:37 +00:00
1 parent da32cac5cf
commit cd9ab2559e
10 files changed
+539 -53

No files matched your search

+76 -13
View File
@@ -25,6 +25,7 @@ from dataclasses import replace
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
from nisse_dev.gates import command_form
from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue
from nisse_dev.loop.driver import Driver, RunOutcome
from nisse_dev.loop.items import (
@@ -81,16 +82,28 @@ the project, and your first step. Wait for their go-ahead. If the request is
unclear, ask_human instead of guessing: a restatement asks no questions, so
the operator can agree to it as it stands.
After the go-ahead, work in a milestone: call start_milestone before
changing anything (never create or switch branches yourself), make the
change, run the project's tests, then call finish_milestone with a summary.
The harness commits, pushes and opens a PR, and you wait for review. Review
comments arrive as messages: make the changes within the same milestone (don't
start another), run the tests, and call finish_milestone again to push the fix. When the PR has merged, call
close_item with what was done, what was verified, and what is left open. If
the PR is closed without merging, ask the operator what next. When a review
needs no change from you, or you are only waiting for the operator to merge
(you can't merge it yourself), call wait_for_review.
The project's documents are in its repository: read ARCHITECTURE.md and any
build plans under docs/plans/ before you restate.
After the go-ahead, work in milestones, one PR each. Call start_milestone
before changing anything (never create or switch branches yourself; each
milestone starts from the latest default branch), make the change, run the
project's tests, then call finish_milestone with a summary, saying whether it
is the final milestone. The harness commits, pushes and opens a PR, and you
wait for review. Review comments arrive as messages: make the changes within
the same milestone (don't start another), run the tests, and call
finish_milestone again to push the fix. When a review needs no change from
you, or you are only waiting for the operator to merge (you can't merge it
yourself), call wait_for_review. The next milestone starts only after the
previous PR has merged. When the last PR has merged, call close_item with
what was done, what was verified, and what is left open. If a PR is closed
without merging, ask the operator what next.
Work longer than about one PR gets a build plan: a Markdown file under
docs/plans/ giving, per milestone, the goal, the interfaces and behaviour, the
edge cases, and the tests that are its acceptance criteria. Commit it with the
first milestone, and mark each milestone done in it in that milestone's PR.
Keep ARCHITECTURE.md current: a design change updates it in the same PR.
{labels}Never label the issue yourself; you may suggest a label to the operator.
@@ -808,6 +821,20 @@ class LoopAgent:
loop,
str(args.get("summary", "")).strip(),
str(args.get("title", "")).strip(),
# Unsaid, it's final: the PR closes the issue, as before.
str(args.get("final", "yes")).strip().lower() != "no",
)
elif outcome.kind == "wait" and outcome.tool == "close_item" and loop.pr:
# Closing leaves nothing open (L2): an open PR is waited out first.
self._fact(
item,
f"close_item waits: PR #{loop.pr} is still open; the item closes "
"after it merges",
)
self._wait(
item,
replace(loop, wait=MERGE, note=f"PR #{loop.pr}"),
f"I'll close this once PR #{loop.pr} merges: {loop.pr_url}",
)
elif outcome.kind == "wait" and outcome.tool == "close_item":
summary = str(args.get("summary", "")).strip()
@@ -828,6 +855,23 @@ class LoopAgent:
item = self._store.work_item(item_id)
assert item is not None
loop = loop_of(item)
if loop.pr:
# D4: the next milestone waits for the merge, read from Gitea now,
# not from the last watch round.
try:
pr = self._gitea.pull_request(self._repo, loop.pr)
except GiteaError as exc:
return f"couldn't check PR #{loop.pr}: {exc}; try again", False
if pr.state == "open":
wait = (
f"milestone {loop.milestone}'s PR #{loop.pr} hasn't merged yet; "
"the next milestone starts after it merges. If you're only "
"waiting for that, call wait_for_review."
)
return wait, False
how = "merged" if pr.merged else "was closed without merging"
self._fact(item, f"PR #{loop.pr} {how}")
loop = replace(loop, pr=0, pr_url="", branch="")
if loop.branch:
busy = f"milestone {loop.milestone} is still open; finish it first"
return busy, False
@@ -835,7 +879,10 @@ class LoopAgent:
return "milestones aren't available here", False
number = loop.milestone + 1
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
self._milestones.start(branch)
try:
self._milestones.start(branch)
except GitError as exc:
return f"couldn't start the milestone: {exc}", False
name = str(args.get("name", "")).strip()
self._fact(item, f"milestone {number} started ({name}) on branch {branch}")
save(
@@ -869,7 +916,12 @@ class LoopAgent:
)
def _finish(
self, item: WorkItem, loop: Loop, summary: str, title: str = ""
self,
item: WorkItem,
loop: Loop,
summary: str,
title: str = "",
final: bool = True,
) -> None:
"""finish_milestone: publish the milestone, or push a review fix."""
if self._milestones is None:
@@ -918,7 +970,12 @@ class LoopAgent:
# D4: finishing without a start cuts the branch now.
number = loop.milestone + 1
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
self._milestones.start(branch)
try:
self._milestones.start(branch)
except GitError as exc:
note = f"I couldn't start milestone {number}: {exc}"
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
return
loop = replace(loop, milestone=number, branch=branch)
published = self._milestones.publish(
item_id=item.id,
@@ -926,6 +983,7 @@ class LoopAgent:
title=title or f"{item.title} (milestone {loop.milestone})",
summary=summary,
issue=loop.issue,
final=final,
)
left = (
f" (left out {len(published.left_out)} build artifact(s))"
@@ -984,6 +1042,11 @@ class LoopAgent:
)
save(self._store, item.id, CLOSED, loop, cause=outcome)
self._store.close_work_item(item.id, outcome=outcome, cause=note)
if self._milestones is not None:
try:
self._milestones.drop_parked(f"{item.id}-")
except GitError:
pass # a leftover side branch; nothing depends on it
if loop.issue:
try:
self._gitea.post_comment(self._repo, loop.issue, f"{outcome}: {note}")