Adopt an already-published PR instead of publishing again
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m12s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m12s
Fixes counterexample 5 from specs/orchestrator.qnt. A crash after the PR was opened but before PR_OPEN was recorded left the agent in EXECUTING; the restart question's 'continue' and 'start' then published again, Gitea refused a second PR, and there was no way back to PR_OPEN. On the live run the executor model happened to notice the existing work; recovery must not depend on that. - Boot (A3 step 4): in EXECUTING, if Gitea shows the milestone's PR open, adopt it, watch it from the start, and resume in PR_OPEN with a notice. Gitea unreachable or the PR closed: the restart question, as before. - MilestonePublisher.publish is idempotent: after pushing, an open PR for the branch is adopted (and so updated) rather than a second one opened. This also covers a PR opened whose response was lost. - The model adopts too, gains the lost-response outcome, and now counts PRs opened per milestone (one_pr_per_milestone was a flag the fixed model could never set). A mutant without adoption fails the new scenario; the properties held over 200,000 traces each. 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
6fdec6922b
commit
8bd1ac0fa2
6 files changed
+165
-24
No files matched your search
@@ -130,6 +130,8 @@ class MilestonePublisher(Protocol):
|
||||
|
||||
def close_pull_request(self, index: int, note: str) -> None: ...
|
||||
|
||||
def published_pull_request(self, task_id: str, milestone: int) -> Any: ...
|
||||
|
||||
|
||||
class PlanBoard(Protocol):
|
||||
"""The Gitea side of plan gating; the concrete one is in
|
||||
@@ -323,6 +325,27 @@ class Orchestrator:
|
||||
"restarted while waiting on you: "
|
||||
+ pending.get("prompt", "my last question still stands")
|
||||
)
|
||||
elif state.state == EXECUTING and (published := self._published_pr()):
|
||||
# A3 step 4: Gitea shows this milestone's PR open, so it was
|
||||
# published and only PR_OPEN went unrecorded. Adopt the PR rather
|
||||
# than ask, and never publish it again. Nothing watched it yet,
|
||||
# so every comment on it is new.
|
||||
milestone = int(self._data.get("milestone", 1))
|
||||
self._data["pr"] = {
|
||||
"number": published.number,
|
||||
"url": published.url,
|
||||
"milestone": milestone,
|
||||
"last_comment": self._watch(
|
||||
"review", published.number, self.handle_review_comment, after=0
|
||||
),
|
||||
}
|
||||
self._unwatch("plan")
|
||||
self._transition(PR_OPEN, cause=f"boot: milestone {milestone} published")
|
||||
self._transport.send_message(
|
||||
f"restarted after milestone {milestone} was published: PR "
|
||||
f"#{published.number} is open ({published.url}); watching it for "
|
||||
"review comments"
|
||||
)
|
||||
elif state.state == PR_OPEN:
|
||||
# Waiting on review is a committed state like a gate (A3 step
|
||||
# 8): resume, and watch the PR again from its cursor.
|
||||
@@ -980,6 +1003,20 @@ class Orchestrator:
|
||||
f"task on {project} abandoned{closed}. Say 'use <project>' to start another"
|
||||
)
|
||||
|
||||
def _published_pr(self) -> Any:
|
||||
"""The open PR for the current milestone, if Gitea shows one; None
|
||||
when there is none or Gitea cannot say (then the restart question
|
||||
is asked as before)."""
|
||||
task_id = self._data.get("task_id")
|
||||
if self._publisher is None or not task_id:
|
||||
return None
|
||||
try:
|
||||
return self._publisher.published_pull_request(
|
||||
str(task_id), int(self._data.get("milestone", 1))
|
||||
)
|
||||
except GiteaError:
|
||||
return None
|
||||
|
||||
def _return_to_pr_open(self, cause: str) -> None:
|
||||
self._transition(PR_OPEN, cause=cause)
|
||||
pr = self._data.get("pr") or {}
|
||||
|
||||
@@ -84,6 +84,19 @@ class MilestonePublisher:
|
||||
cause=f"publishing milestone {milestone}",
|
||||
)
|
||||
self._git.push(self._remote, branch, token=self._token)
|
||||
# Idempotent: if a PR for this branch is already open (a crash after
|
||||
# opening it, or a response lost on the way back), the push above
|
||||
# has updated it; adopt it rather than fail on a second.
|
||||
existing = self.published_pull_request(task_id, milestone)
|
||||
if existing is not None:
|
||||
self._store.commit_intent(
|
||||
intent_id, cause=f"adopted open PR #{existing.number}"
|
||||
)
|
||||
return PublishResult(
|
||||
existing,
|
||||
f"PR #{existing.number} was already open for this milestone; "
|
||||
f"pushed to it: {existing.url}",
|
||||
)
|
||||
pr = self._client.open_pull_request(
|
||||
self._repo,
|
||||
head=branch,
|
||||
@@ -94,6 +107,17 @@ class MilestonePublisher:
|
||||
self._store.commit_intent(intent_id, cause=f"opened PR #{pr.number}")
|
||||
return PublishResult(pr, f"opened PR #{pr.number}: {pr.url}")
|
||||
|
||||
def published_pull_request(
|
||||
self, task_id: str, milestone: int
|
||||
) -> PullRequest | None:
|
||||
"""The open PR for a milestone's branch, if there is one. A closed one
|
||||
does not count: the operator closed it, so a new one may be opened.
|
||||
Raises GiteaError when Gitea cannot say."""
|
||||
pr = self._client.find_pull_request(
|
||||
self._repo, self.branch_for(task_id, milestone)
|
||||
)
|
||||
return pr if pr is not None and pr.state == "open" else None
|
||||
|
||||
def comment(self, index: int, body: str) -> None:
|
||||
"""Reply on the PR. Courtesy only — the pushed commit is the real
|
||||
answer — so a failure here must not derail the review loop."""
|
||||
|
||||
Reference in new issue
Block a user