Files
nisse/nisse-dev/tests/test_loop_multi.py
T
dongarandClaude Opus 5.5 88c7d2e8c9
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m4s
CI / e2e (pull_request) Successful in 24m22s
A design milestone holds documents only, enforced
Found by the first local e2e run: qwen3.8-27b put a new project's code in
the same PR as its ARCHITECTURE.md, with no build plan. D1 says a `design`
item agrees its design before any code, and that rested on wording only.

- Until a PR of a `design` item has merged, finish_milestone is sent back
  if the milestone changes anything but ARCHITECTURE.md and docs/, naming
  the paths to take out. The merge is recorded (design_merged) and code
  may follow.
- The label's instruction and the new project's brief say so plainly:
  documents first, in a PR of their own; code after it has merged.

Tests: a design milestone with code is sent back and only the documents
are published, then code after the merge; the walk's model follows the
rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 15:39:00 +00:00

219 lines
7.2 KiB
Python

"""Multi-milestone work on the new loop (M2.4): each milestone starts from
the latest default branch, the next waits for the previous PR's merge (read
from Gitea when asked), earlier PRs are part of the issue and the last closes
it, and the item closes only with nothing left open (D1, D2, D4). Real git on
the throwaway build host; a fake Gitea."""
from typing import Any
from test_loop_milestones import (
RESTATE,
Forge,
ScriptedModel,
call,
git,
item,
loop_agent,
say,
)
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.canonical import ToolCall, ToolResult
from nisse_dev.gitea import Issue
from nisse_dev.loop.items import MERGE, WAITING, loop_of
def milestone(path: str, final: str) -> list[Any]:
return [
call("start_milestone", name=path),
call("write_file", path=path, content=f"# {path}\n"),
call("finish_milestone", summary=f"adds {path}", title=path, final=final),
]
def tree(world: dict[str, Any], branch: str) -> list[str]:
bare = str(world["bare"])
return git("--git-dir", bare, "ls-tree", "--name-only", branch).split()
def result_of(world: dict[str, Any], tool: str) -> str:
events = world["store"].transcript(item(world).id, "driver").read()
ids = {
e.event.call_id
for e in events
if isinstance(e.event, ToolCall) and e.event.tool == tool
}
results = [
e.event.result
for e in events
if isinstance(e.event, ToolResult) and e.event.call_id in ids
]
return results[-1]
def test_the_second_milestone_builds_on_the_merged_first(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
milestone("a.py", final="no"),
milestone("b.py", final="yes"),
[call("close_item", summary="both done")],
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[first] = forge.prs
assert forge.prs[first]["body"].endswith("Part of #14")
forge.merge(first)
agent.watch()
agent._schedule()
second = max(forge.prs)
assert second != first
assert "Closes #14" in forge.prs[second]["body"]
assert forge.files(second) == ["b.py"] # only its own work
head = forge.prs[second]["head"]
assert "a.py" in tree(world, head) # cut from main, with the first merged
assert loop_of(item(world)).milestone == 2
forge.merge(second)
agent.watch()
agent._schedule()
assert item(world).status == "closed"
def test_the_next_milestone_waits_for_the_merge(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
milestone("a.py", final="no"),
[ # a message arrives while the PR is open: try to move on
call("start_milestone", name="b"),
call("wait_for_review", note="waiting for the merge"),
],
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
say(transport, world, "how's it going?")
assert "hasn't merged yet" in result_of(world, "start_milestone")
assert (item(world).position, loop_of(item(world)).wait) == (WAITING, MERGE)
assert len(forge.prs) == 1
def test_a_merge_not_yet_watched_is_read_from_gitea(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
milestone("a.py", final="no"),
milestone("b.py", final="yes"),
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[first] = forge.prs
forge.merge(first) # merged in Gitea; no watch round has seen it
say(transport, world, "merged, carry on")
assert len(forge.prs) == 2
assert "a.py" in tree(world, forge.prs[max(forge.prs)]["head"])
def test_a_redo_after_a_closed_pr_drops_the_rejected_work(
world: dict[str, Any],
) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
milestone("a.py", final="yes"),
milestone("c.py", final="yes"),
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[first] = forge.prs
forge.prs[first]["state"] = "closed" # rejected
agent.watch()
agent._schedule()
redo = max(forge.prs)
assert forge.files(redo) == ["c.py"]
assert "a.py" not in tree(world, forge.prs[redo]["head"])
def test_closing_with_a_pr_open_waits_for_the_merge(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
milestone("a.py", final="yes"),
[call("close_item", summary="done, I think")], # too soon
[call("close_item", summary="done")],
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
say(transport, world, "thanks")
assert item(world).status == "open"
assert (item(world).position, loop_of(item(world)).wait) == (WAITING, MERGE)
[pr] = forge.prs
forge.merge(pr)
agent.watch()
agent._schedule()
assert item(world).status == "closed"
def test_an_unsaid_final_closes_the_issue(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
model = ScriptedModel(
RESTATE,
[
call("start_milestone", name="a"),
call("write_file", path="a.py", content="x = 1\n"),
call("finish_milestone", summary="adds a"),
],
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[pr] = forge.prs
assert "Closes #14" in forge.prs[pr]["body"]
def test_a_design_milestone_holds_documents_only(world: dict[str, Any]) -> None:
"""D1, D2: a `design` item agrees its design before any code; the
harness refuses a design milestone with code in it."""
forge: Forge = world["forge"]
forge.issues[0] = Issue(14, "Add count_words", "count words", "don", ("design",))
model = ScriptedModel(
RESTATE,
[
call("start_milestone", name="design"),
call("write_file", path="ARCHITECTURE.md", content="# arch\n"),
call("write_file", path="docs/plans/words.md", content="# plan\n"),
call("write_file", path="words.py", content="W = 1\n"), # too soon
call("finish_milestone", summary="design", final="no"),
],
[
call("run_command", command="rm words.py"),
call("finish_milestone", summary="design", final="no"),
],
milestone("words.py", final="yes"), # after the design merged
)
agent, transport = loop_agent(world, model)
agent.intake()
say(transport, world, "go")
[design] = forge.prs
assert sorted(forge.files(design)) == ["ARCHITECTURE.md", "docs/plans/words.md"]
refused = [
e.event.result
for e in world["store"].transcript(item(world).id, "driver").read()
if isinstance(e.event, ToolResult)
and "this milestone is the design" in e.event.result
]
assert refused and "words.py" in refused[0]
forge.merge(design)
agent.watch()
agent._schedule()
code = max(forge.prs)
assert code != design and forge.files(code) == ["words.py"]
assert loop_of(item(world)).design_merged