Milestones and review in the new loop (M2.1, PR C)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m48s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m48s
A work item starts from a fresh workspace: fetch, set aside (never discard) leftover changes, branches and unpushed commits, a clean default branch, and a branch of its own (D3, #62). start_milestone cuts the milestone's branch; finish_milestone commits only the work, pushes and opens a PR that closes the issue, under an intent, adopting an open PR and treating a closed one as the operator's answer (D4). Between runs the loop watches each item's PR: operator comments and inline review notes (#69) reach the item, a merge or close hands back to the driver. A review fix is checked after it's pushed and goes out as a new PR if the PR merged first (D5). At boot an interrupted publish is settled against Gitea (L8). 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
f84bb70839
commit
21744efac1
9 files changed
+1004
-10
No files matched your search
@@ -26,6 +26,7 @@ from nisse_dev.loop.items import (
|
||||
ANSWER,
|
||||
CLOSED,
|
||||
GO_AHEAD,
|
||||
MERGE,
|
||||
READY,
|
||||
RUNNING,
|
||||
WAITING,
|
||||
@@ -35,7 +36,14 @@ from nisse_dev.loop.items import (
|
||||
loop_of,
|
||||
save,
|
||||
)
|
||||
from nisse_dev.loop.tools import ASK_HUMAN, CLOSE_ITEM, RESTATE
|
||||
from nisse_dev.loop.milestones import OPEN_PR, Milestones
|
||||
from nisse_dev.loop.tools import (
|
||||
ASK_HUMAN,
|
||||
CLOSE_ITEM,
|
||||
FINISH_MILESTONE,
|
||||
RESTATE,
|
||||
START_MILESTONE,
|
||||
)
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore, WorkItem
|
||||
from nisse_dev.tools import EXECUTOR_TOOLS, Workspace
|
||||
@@ -58,8 +66,14 @@ understanding to the operator: what you take the request to mean, how it fits
|
||||
the project, and your first step. Wait for their go-ahead. If the request is
|
||||
unclear, ask_human instead of guessing.
|
||||
|
||||
After the go-ahead, do the work. When it is done, call close_item with a
|
||||
summary of what was done, what was verified, and what is left open.
|
||||
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, 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.
|
||||
|
||||
{workspace}"""
|
||||
|
||||
@@ -82,6 +96,7 @@ class LoopAgent:
|
||||
cancel: Cancel,
|
||||
oplog: OpLog,
|
||||
roles: dict[str, str] | None = None,
|
||||
milestones: Milestones | None = None,
|
||||
intake_interval_s: float = INTAKE_INTERVAL_S,
|
||||
) -> None:
|
||||
self._store = store
|
||||
@@ -98,6 +113,7 @@ class LoopAgent:
|
||||
self._cancel = cancel
|
||||
self._oplog = oplog
|
||||
self._roles = roles or {}
|
||||
self._milestones = milestones
|
||||
self._interval = intake_interval_s
|
||||
# thread root -> revision on screen there, read on the transport's
|
||||
# thread to stamp replies on arrival (L3).
|
||||
@@ -145,6 +161,7 @@ class LoopAgent:
|
||||
"""Resume waiting points as they were, and never resume interrupted
|
||||
work blindly: an item cut off mid-run waits on the operator."""
|
||||
self._store.set_active(None, cause="boot")
|
||||
self._reconcile_publishing()
|
||||
for item in self._store.work_items("open"):
|
||||
loop = loop_of(item)
|
||||
if item.position == RUNNING:
|
||||
@@ -164,6 +181,37 @@ class LoopAgent:
|
||||
thread=loop.thread,
|
||||
)
|
||||
|
||||
def _reconcile_publishing(self) -> None:
|
||||
"""L8 step 2: a publish cut off by a crash is settled against Gitea.
|
||||
An open PR for the branch is adopted; otherwise the intent is aborted
|
||||
and the operator is told, never a blind retry."""
|
||||
for intent in self._store.pending_intents():
|
||||
if intent.kind != OPEN_PR or not intent.work_item:
|
||||
continue
|
||||
item = self._store.work_item(intent.work_item)
|
||||
branch = str(intent.payload.get("branch", ""))
|
||||
try:
|
||||
pr = self._gitea.find_pull_request(self._repo, branch)
|
||||
except GiteaError:
|
||||
continue # unverifiable: left pending, asked about below
|
||||
if item is None or item.status != "open":
|
||||
self._store.abort_intent(intent.id, cause="item no longer open")
|
||||
elif pr is not None and pr.state == "open":
|
||||
self._store.commit_intent(intent.id, cause=f"adopted PR #{pr.number}")
|
||||
loop = replace(loop_of(item), pr=pr.number, pr_url=pr.url)
|
||||
self._fact(item, f"after a restart, adopted PR #{pr.number} ({pr.url})")
|
||||
save(
|
||||
self._store,
|
||||
item.id,
|
||||
WAITING,
|
||||
replace(loop, wait=MERGE, note=f"PR #{pr.number}"),
|
||||
cause="adopted PR after restart",
|
||||
)
|
||||
else:
|
||||
self._store.abort_intent(intent.id, cause="no PR was opened")
|
||||
note = "publishing was cut off by a restart; reply to try again."
|
||||
self._wait(item, replace(loop_of(item), wait=ANSWER, note=note), note)
|
||||
|
||||
# -- intake (D1) ---------------------------------------------------------
|
||||
|
||||
def intake(self) -> None:
|
||||
@@ -185,8 +233,66 @@ class LoopAgent:
|
||||
)
|
||||
continue
|
||||
self._open(issue)
|
||||
self.watch()
|
||||
self._schedule()
|
||||
|
||||
# -- PR review and merge (D4, D5) -------------------------------------------
|
||||
|
||||
def watch(self) -> None:
|
||||
"""New operator comments on an item's PR, and its merge or close,
|
||||
reach the item; a waiting item becomes ready to act on them."""
|
||||
for item in self._store.work_items("open"):
|
||||
loop = loop_of(item)
|
||||
if not loop.pr:
|
||||
continue
|
||||
try:
|
||||
pr = self._gitea.pull_request(self._repo, loop.pr)
|
||||
comments = self._gitea.list_comments(self._repo, loop.pr)
|
||||
notes = self._gitea.review_notes(self._repo, loop.pr)
|
||||
except GiteaError as exc:
|
||||
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
|
||||
continue
|
||||
log = self._store.transcript(item.id, "operator")
|
||||
new = False
|
||||
for comment in comments:
|
||||
if comment.id <= loop.comments:
|
||||
continue
|
||||
loop = replace(loop, comments=comment.id)
|
||||
if comment.author in self._gitea_operators:
|
||||
log.append(
|
||||
UserMessage(
|
||||
text=comment.body,
|
||||
source=f"gitea:pr:{loop.pr}:comment:{comment.id}",
|
||||
)
|
||||
)
|
||||
new = True
|
||||
for note in notes:
|
||||
if note.key in loop.notes:
|
||||
continue
|
||||
loop = replace(loop, notes=(*loop.notes, note.key))
|
||||
if note.author in self._gitea_operators:
|
||||
where = f"on {note.where}: " if note.where else ""
|
||||
log.append(
|
||||
UserMessage(
|
||||
text=f"{where}{note.body}",
|
||||
source=f"gitea:pr:{loop.pr}:review:{note.key}",
|
||||
)
|
||||
)
|
||||
new = True
|
||||
position = item.position
|
||||
if pr.merged:
|
||||
self._fact(item, f"PR #{loop.pr} merged")
|
||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||
elif pr.state != "open":
|
||||
self._fact(item, f"PR #{loop.pr} was closed without merging")
|
||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||
elif new and item.position == WAITING:
|
||||
position = READY
|
||||
if position != item.position or loop != loop_of(item):
|
||||
if position == READY:
|
||||
loop = replace(loop, wait="", note="")
|
||||
save(self._store, item.id, position, loop, cause="PR update")
|
||||
|
||||
def _from_an_operator(self, issue: Issue) -> bool:
|
||||
"""L11: only an operator's issue, or one an operator assigned, is
|
||||
an instruction."""
|
||||
@@ -326,11 +432,30 @@ class LoopAgent:
|
||||
def _run(self, item: WorkItem) -> None:
|
||||
loop = loop_of(item)
|
||||
self._store.set_active(item.id, cause="ready")
|
||||
if not loop.prepared and self._milestones is not None:
|
||||
# D3: a fresh workspace when a work item starts.
|
||||
try:
|
||||
notes = self._milestones.prepare(f"{item.id}-work")
|
||||
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
|
||||
note = f"I couldn't prepare the workspace: {exc}. Reply to retry."
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
self._store.set_active(None, cause="workspace failed")
|
||||
return
|
||||
self._fact(item, "workspace prepared from the latest default branch")
|
||||
if notes:
|
||||
said = "Set aside from the workspace: " + "; ".join(notes)
|
||||
self._fact(item, said)
|
||||
self._transport.post(said, thread=loop.thread)
|
||||
loop = replace(loop, prepared=True)
|
||||
save(self._store, item.id, RUNNING, loop, cause="driver run")
|
||||
tools = [
|
||||
RESTATE,
|
||||
ASK_HUMAN,
|
||||
*(_LOOK if not loop.started else [CLOSE_ITEM, *EXECUTOR_TOOLS]),
|
||||
*(
|
||||
_LOOK
|
||||
if not loop.started
|
||||
else [START_MILESTONE, FINISH_MILESTONE, CLOSE_ITEM, *EXECUTOR_TOOLS]
|
||||
),
|
||||
]
|
||||
system = SYSTEM.format(
|
||||
repo=self._repo,
|
||||
@@ -345,6 +470,7 @@ class LoopAgent:
|
||||
system=system,
|
||||
tools=tools,
|
||||
workspace=self._workspace,
|
||||
handlers={"start_milestone": lambda args: self._start(item.id, args)},
|
||||
)
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -372,6 +498,8 @@ class LoopAgent:
|
||||
elif outcome.kind == "wait" and outcome.tool == "ask_human":
|
||||
question = str(args.get("question", "")).strip()
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=question), question)
|
||||
elif outcome.kind == "wait" and outcome.tool == "finish_milestone":
|
||||
self._finish(item, loop, str(args.get("summary", "")).strip())
|
||||
elif outcome.kind == "wait" and outcome.tool == "close_item":
|
||||
summary = str(args.get("summary", "")).strip()
|
||||
self._transport.post(f"Done: {summary}", thread=loop.thread)
|
||||
@@ -384,6 +512,123 @@ class LoopAgent:
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
self._store.set_active(None, cause="item waiting")
|
||||
|
||||
# -- milestones (D4) -----------------------------------------------------
|
||||
|
||||
def _start(self, item_id: str, args: dict[str, object]) -> tuple[str, bool]:
|
||||
"""start_milestone, mid-run: cut the milestone's branch."""
|
||||
item = self._store.work_item(item_id)
|
||||
assert item is not None
|
||||
loop = loop_of(item)
|
||||
if loop.branch:
|
||||
busy = f"milestone {loop.milestone} is still open; finish it first"
|
||||
return busy, False
|
||||
if self._milestones is None:
|
||||
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)
|
||||
name = str(args.get("name", "")).strip()
|
||||
self._fact(item, f"milestone {number} started ({name}) on branch {branch}")
|
||||
save(
|
||||
self._store,
|
||||
item.id,
|
||||
RUNNING,
|
||||
replace(loop, milestone=number, branch=branch),
|
||||
cause=f"milestone {number} started",
|
||||
)
|
||||
return f"started milestone {number} on branch {branch}", True
|
||||
|
||||
def _finish(self, item: WorkItem, loop: Loop, summary: str) -> None:
|
||||
"""finish_milestone: publish the milestone, or push a review fix."""
|
||||
if self._milestones is None:
|
||||
note = "milestones aren't available here"
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
return
|
||||
if loop.pr:
|
||||
fixed = self._milestones.push_fix(
|
||||
item_id=item.id,
|
||||
branch=loop.branch,
|
||||
pr=loop.pr,
|
||||
message=summary or "Address review comments",
|
||||
attempt=loop.fixes + 1,
|
||||
)
|
||||
self._fact(item, f"review fix: {fixed.note}")
|
||||
if fixed.kind in ("pushed", "nothing"):
|
||||
self._gitea_comment(loop.pr, fixed.note)
|
||||
self._wait(
|
||||
item,
|
||||
replace(loop, wait=MERGE, note=f"PR #{loop.pr}"),
|
||||
f"PR #{loop.pr}: {fixed.note}",
|
||||
)
|
||||
elif fixed.kind == "new-pr" and fixed.pr is not None:
|
||||
loop = replace(
|
||||
loop, pr=fixed.pr.number, pr_url=fixed.pr.url, fixes=loop.fixes + 1
|
||||
)
|
||||
self._wait(
|
||||
item,
|
||||
replace(loop, wait=MERGE, note=f"PR #{fixed.pr.number}"),
|
||||
f"The PR merged before the fix landed, so it's a new PR: "
|
||||
f"#{fixed.pr.number} {fixed.pr.url}",
|
||||
)
|
||||
else:
|
||||
self._wait(
|
||||
item,
|
||||
replace(loop, wait=ANSWER, note=fixed.note),
|
||||
f"{fixed.note}. What next?",
|
||||
)
|
||||
return
|
||||
if not loop.branch:
|
||||
# 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)
|
||||
loop = replace(loop, milestone=number, branch=branch)
|
||||
published = self._milestones.publish(
|
||||
item_id=item.id,
|
||||
branch=loop.branch,
|
||||
title=f"{item.title} (milestone {loop.milestone})",
|
||||
summary=summary,
|
||||
issue=loop.issue,
|
||||
)
|
||||
left = (
|
||||
f" (left out {len(published.left_out)} build artifact(s))"
|
||||
if published.left_out
|
||||
else ""
|
||||
)
|
||||
if published.pr is not None and published.kind in ("opened", "adopted"):
|
||||
pr = published.pr
|
||||
self._fact(
|
||||
item,
|
||||
f"milestone {loop.milestone} published: PR #{pr.number} "
|
||||
f"({pr.url}){left}",
|
||||
)
|
||||
self._wait(
|
||||
item,
|
||||
replace(
|
||||
loop,
|
||||
pr=pr.number,
|
||||
pr_url=pr.url,
|
||||
wait=MERGE,
|
||||
note=f"PR #{pr.number}",
|
||||
),
|
||||
f"Milestone {loop.milestone} is up for review: {pr.url}{left}",
|
||||
)
|
||||
else:
|
||||
self._fact(
|
||||
item, f"milestone {loop.milestone} not published: {published.note}"
|
||||
)
|
||||
self._wait(
|
||||
item,
|
||||
replace(loop, wait=ANSWER, note=published.note),
|
||||
f"Milestone {loop.milestone}: {published.note}. What next?",
|
||||
)
|
||||
|
||||
def _gitea_comment(self, pr: int, text: str) -> None:
|
||||
try:
|
||||
self._gitea.post_comment(self._repo, pr, text)
|
||||
except GiteaError:
|
||||
pass # courtesy only; the pushed commit is the answer
|
||||
|
||||
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
||||
if say:
|
||||
self._transport.post(say, thread=loop.thread)
|
||||
|
||||
Reference in new issue
Block a user