Multi-milestone work and documents on the new loop (M2.4)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s
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:
1 parent
da32cac5cf
commit
cd9ab2559e
10 files changed
+539
-53
No files matched your search
@@ -0,0 +1,176 @@
|
||||
"""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.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"]
|
||||
@@ -79,7 +79,11 @@ class PolicyModel:
|
||||
return [
|
||||
call("start_milestone", name=f"m{self.n}"),
|
||||
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"),
|
||||
call("finish_milestone", summary=f"work {self.n}"),
|
||||
call(
|
||||
"finish_milestone",
|
||||
summary=f"work {self.n}",
|
||||
final=self.rng.choice(["yes", "no"]),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -308,6 +312,7 @@ class Walk:
|
||||
f"one_active (nothing left running): {where}"
|
||||
)
|
||||
self._parked_work_safe(where)
|
||||
self._milestones_start_fresh(where)
|
||||
heads = [p["head"] for p in forge.prs.values()]
|
||||
assert len(heads) == len(set(heads)), f"one_pr_per_milestone: {where}"
|
||||
bare = self.world["bare"]
|
||||
@@ -325,6 +330,29 @@ class Walk:
|
||||
)
|
||||
assert landed, f"review_fixes_land (PR {n}): {where}"
|
||||
|
||||
def _milestones_start_fresh(self, where: str) -> None:
|
||||
"""D4: a milestone starts from the latest default branch, so one
|
||||
redone after a closed PR never carries the rejected work."""
|
||||
forge: Forge = self.world["forge"]
|
||||
bare = self.world["bare"]
|
||||
for item in self.items():
|
||||
mine = sorted(
|
||||
(n, p)
|
||||
for n, p in forge.prs.items()
|
||||
if p["head"].startswith(f"{item.id}-milestone-")
|
||||
and "-fix-" not in p["head"]
|
||||
)
|
||||
for i, (n, earlier) in enumerate(mine):
|
||||
if earlier["merged"]:
|
||||
continue
|
||||
rejected = _rev(bare, f"refs/heads/{earlier['head']}")
|
||||
if _is_ancestor(bare, rejected, "refs/heads/main"):
|
||||
continue
|
||||
for m, later in mine[i + 1 :]:
|
||||
assert not _is_ancestor(
|
||||
bare, rejected, f"refs/heads/{later['head']}"
|
||||
), f"milestones_start_fresh: PR {m} carries PR {n}: {where}"
|
||||
|
||||
def _parked_work_safe(self, where: str) -> None:
|
||||
"""L1, D4: nothing of a parked item lives only on the build VM: what
|
||||
every branch of an open item that isn't on the workspace points at is
|
||||
@@ -367,7 +395,17 @@ class Walk:
|
||||
for path in written:
|
||||
if here and (root / path).exists():
|
||||
continue
|
||||
history = _git(bare, "log", "--all", "--format=%H", "--", path)
|
||||
history = _git(
|
||||
# Full history: a parked stash keeps new files in its
|
||||
# third parent, which plain log simplifies away.
|
||||
bare,
|
||||
"log",
|
||||
"--all",
|
||||
"--full-history",
|
||||
"--format=%H",
|
||||
"--",
|
||||
path,
|
||||
)
|
||||
assert history, f"parked_work_safe: {path} of #{item.id} lost: {where}"
|
||||
|
||||
|
||||
|
||||
@@ -88,12 +88,14 @@ def test_switching_parks_the_work_and_resuming_finds_it(
|
||||
agent.watch()
|
||||
agent._schedule()
|
||||
|
||||
# Switching to 14 parked 15: its half-done work is committed and pushed.
|
||||
# Switching to 14 parked 15: its half-done work is pushed as a stash to
|
||||
# a side branch (new files in its third parent), never onto 15's branch.
|
||||
b_branch = loop_of(item(world, 15)).branch
|
||||
pushed = git(
|
||||
"--git-dir", str(world["bare"]), "show", f"refs/heads/{b_branch}:lines.py"
|
||||
)
|
||||
assert pushed == "L = 1"
|
||||
bare = ["--git-dir", str(world["bare"])]
|
||||
parked = f"refs/heads/{item(world, 15).id}-parked"
|
||||
assert git(*bare, "show", f"{parked}^3:lines.py") == "L = 1"
|
||||
# The branch has no commits of its own, so it wasn't pushed at all.
|
||||
assert b_branch not in git(*bare, "branch", "--list")
|
||||
# 14's fix went to its own branch, without 15's work.
|
||||
assert loop_of(item(world, 14)).wait == MERGE
|
||||
files = git("--git-dir", str(world["bare"]), "ls-tree", "--name-only", a_branch)
|
||||
@@ -105,6 +107,8 @@ def test_switching_parks_the_work_and_resuming_finds_it(
|
||||
say(transport, world, 15, "one per line")
|
||||
assert world["repo"].current_branch() == b_branch
|
||||
assert (world["repo"].root / "lines.py").read_text() == "L = 1\n"
|
||||
# Put back, so the side branch is gone.
|
||||
assert item(world, 15).id + "-parked" not in git(*bare, "branch", "--list")
|
||||
assert loop_of(item(world, 15)).wait == MERGE
|
||||
assert len(forge.prs) == 2
|
||||
|
||||
@@ -190,3 +194,52 @@ def test_status_shows_the_line(world: dict[str, Any]) -> None:
|
||||
assert "#15 Add count_lines: ready, 2 of 2 in line" in agent.status()
|
||||
agent.handle(InboundMessage(DON, "do #15 next"), None)
|
||||
assert "#15 Add count_lines: ready, 1 of 2 in line" in agent.status()
|
||||
|
||||
|
||||
def test_a_fix_half_done_when_its_pr_merges_survives_a_switch(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
"""Found by the walk: work on a review fix, parked after the PR merged,
|
||||
was pushed onto the merged branch, where nobody would see it. Now it
|
||||
comes back with the item and goes out in its next PR."""
|
||||
forge: Forge = world["forge"]
|
||||
two_issues(forge)
|
||||
model = ScriptedModel(
|
||||
[call("restate", text="count_words")], # 14
|
||||
[call("restate", text="count_lines")], # 15
|
||||
[ # 14: a PR
|
||||
call("start_milestone", name="words"),
|
||||
call("write_file", path="words.py", content="W = 1\n"),
|
||||
call("finish_milestone", summary="words", final="yes"),
|
||||
],
|
||||
[ # 14, a review comment: half a fix, then a question
|
||||
call("write_file", path="fix.py", content="F = 1\n"),
|
||||
call("ask_human", question="fix it in words.py too?"),
|
||||
],
|
||||
[ # 15 runs meanwhile
|
||||
call("start_milestone", name="lines"),
|
||||
call("write_file", path="lines.py", content="L = 1\n"),
|
||||
call("finish_milestone", summary="lines"),
|
||||
],
|
||||
[call("finish_milestone", summary="the fix", final="yes")], # 14 again
|
||||
)
|
||||
agent, transport = loop_agent(world, model)
|
||||
agent.intake()
|
||||
say(transport, world, 14, "go")
|
||||
[pr] = forge.prs
|
||||
forge.comments[pr] = [Comment(7, "don", "add fix.py", "t")]
|
||||
agent.watch()
|
||||
agent._schedule()
|
||||
assert loop_of(item(world, 14)).wait == ANSWER
|
||||
say(transport, world, 15, "go") # parks 14's half-done fix
|
||||
forge.merge(pr)
|
||||
agent.watch()
|
||||
agent._schedule() # 14 resumes; its fix goes out as a new PR
|
||||
|
||||
bare = ["--git-dir", str(world["bare"])]
|
||||
merged_head = forge.prs[pr]["head"]
|
||||
assert "fix.py" not in git(*bare, "ls-tree", "--name-only", merged_head).split()
|
||||
newest = max(
|
||||
n for n, p in forge.prs.items() if p["head"].startswith(item(world, 14).id)
|
||||
)
|
||||
assert newest != pr and forge.files(newest) == ["fix.py"]
|
||||
Reference in new issue
Block a user