Answer where asked, wait on a merge, and let the driver title PRs
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s

From the new loop's first live run (sandbox #255, PR #256):
- A question asked on the PR is answered on the PR (an inline one quoted
  with its file and line, since the API can't reply in its thread), with
  a pointer in the item's Matrix thread; one asked in Matrix stays there.
- wait_for_review: when a review needs no change, or only the operator's
  merge is left, the item waits on the merge instead of on an answer.
- finish_milestone takes the PR's title from the driver.
The e2e test's merge waits out Gitea's brief 'try again later' after a
push.

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-03 17:29:28 +00:00
1 parent bf416cad09
commit 651551aab4
6 files changed
+198 -13

No files matched your search

+68 -7
View File
@@ -43,6 +43,7 @@ from nisse_dev.loop.tools import (
FINISH_MILESTONE,
RESTATE,
START_MILESTONE,
WAIT_FOR_REVIEW,
)
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore, WorkItem
@@ -73,7 +74,9 @@ 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.
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.
{workspace}"""
@@ -265,6 +268,12 @@ class LoopAgent:
source=f"gitea:pr:{loop.pr}:comment:{comment.id}",
)
)
loop = replace(
loop,
asked_on_pr=loop.pr,
asked_where="",
asked_text=comment.body[:200],
)
new = True
for note in notes:
if note.key in loop.notes:
@@ -278,6 +287,12 @@ class LoopAgent:
source=f"gitea:pr:{loop.pr}:review:{note.key}",
)
)
loop = replace(
loop,
asked_on_pr=loop.pr,
asked_where=note.where,
asked_text=note.body[:200],
)
new = True
position = item.position
if pr.merged:
@@ -372,7 +387,10 @@ class LoopAgent:
self._store.transcript(item.id, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
loop = loop_of(item)
loop = replace(loop_of(item), asked_on_pr=0, asked_where="", asked_text="")
if loop != loop_of(item):
save(self._store, item.id, item.position, loop, cause="operator spoke")
item = self._store.work_item(item.id) or item
if command == "abandon":
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif item.position == WAITING and loop.wait == GO_AHEAD and command in _GO:
@@ -454,7 +472,13 @@ class LoopAgent:
*(
_LOOK
if not loop.started
else [START_MILESTONE, FINISH_MILESTONE, CLOSE_ITEM, *EXECUTOR_TOOLS]
else [
START_MILESTONE,
FINISH_MILESTONE,
CLOSE_ITEM,
*([WAIT_FOR_REVIEW] if loop.pr else []),
*EXECUTOR_TOOLS,
]
),
]
system = SYSTEM.format(
@@ -497,9 +521,23 @@ class LoopAgent:
self._wait(item, replace(loop, rev=rev, wait=GO_AHEAD, note=""), None)
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)
self._answer_where_asked(loop, question)
self._wait(item, replace(loop, wait=ANSWER, note=question), None)
elif outcome.kind == "wait" and outcome.tool == "wait_for_review":
note = str(args.get("note", "")).strip() or f"waiting on PR #{loop.pr}"
if loop.pr:
self._answer_where_asked(loop, note)
self._wait(item, replace(loop, wait=MERGE, note=f"PR #{loop.pr}"), None)
else:
say = f"{note} (there's no open PR, so I need you to tell me what next)"
self._wait(item, replace(loop, wait=ANSWER, note=say), say)
elif outcome.kind == "wait" and outcome.tool == "finish_milestone":
self._finish(item, loop, str(args.get("summary", "")).strip())
self._finish(
item,
loop,
str(args.get("summary", "")).strip(),
str(args.get("title", "")).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)
@@ -538,7 +576,30 @@ class LoopAgent:
)
return f"started milestone {number} on branch {branch}", True
def _finish(self, item: WorkItem, loop: Loop, summary: str) -> None:
def _answer_where_asked(self, loop: Loop, text: str) -> None:
"""D5: answer where the operator asked. A PR question is answered on
the PR, quoting the line it was about (an inline thread can't be
replied to through the API), with a pointer in the Matrix thread."""
if not loop.asked_on_pr:
self._transport.post(text, thread=loop.thread)
return
quoted = [f"> on `{loop.asked_where}`:"] if loop.asked_where else []
quoted += [f"> {line}" for line in loop.asked_text.splitlines()]
body = "\n".join([*quoted, "", text])
try:
self._gitea.post_comment(self._repo, loop.asked_on_pr, body)
except GiteaError:
self._transport.post(text, thread=loop.thread) # the PR is unreachable
return
url = loop.pr_url or f"PR #{loop.asked_on_pr}"
self._transport.post(
f"I answered your comment on PR #{loop.asked_on_pr}: {url}",
thread=loop.thread,
)
def _finish(
self, item: WorkItem, loop: Loop, summary: str, title: str = ""
) -> None:
"""finish_milestone: publish the milestone, or push a review fix."""
if self._milestones is None:
note = "milestones aren't available here"
@@ -591,7 +652,7 @@ class LoopAgent:
published = self._milestones.publish(
item_id=item.id,
branch=loop.branch,
title=f"{item.title} (milestone {loop.milestone})",
title=title or f"{item.title} (milestone {loop.milestone})",
summary=summary,
issue=loop.issue,
)