Adopt an already-published PR instead of publishing again
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:
dongarandClaude Opus 5.5 committed 2026-09-29 19:58:19 +00:00
1 parent 6fdec6922b
commit 8bd1ac0fa2
6 files changed
+165 -24

No files matched your search

+37
View File
@@ -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 {}