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

+23 -11
View File
@@ -67,6 +67,7 @@ module orchestrator {
prThread: List[PrComment],
nextId: int,
giteaPr: int -> str, // PR key -> "open" / "closed"
opened: int -> int, // PR key -> how many PRs Gitea ever opened for it
openIssues: Set[int], // plan issues still open, by task
// -- volatile: lost in a crash --
queue: List[Job],
@@ -83,7 +84,6 @@ module orchestrator {
answered: Set[int], // ... and answered (pushed, asked, or reported)
badApproval: bool,
staleFeedback: bool,
dupPublish: bool,
}
// ---- helpers ------------------------------------------------------------
@@ -97,6 +97,11 @@ module orchestrator {
pure def prIds(s: Sys, key: int): int =
s.prThread.foldl(0, (acc, c) => if (c.key == key and c.id > acc) c.id else acc)
pure def max(a: int, b: int): int = if (a > b) a else b
// Gitea opens a PR for a branch.
pure def openPr(s: Sys, key: int): Sys =
{ ...s, giteaPr: s.giteaPr.put(key, "open"),
opened: s.opened.put(key, (if (s.opened.keys().contains(key)) s.opened.get(key) else 0) + 1) }
pure def queued(s: Sys, src: str): bool = s.queue.foldl(false, (acc, j) => acc or j.src == src)
// _await_input
@@ -260,6 +265,11 @@ module orchestrator {
awaitInput(s0, { kind: "boot", g: "", interrupted: s.st })
else if (s.st == "AWAIT_INPUT" and s.pending.kind != "")
s0
else if (s.st == "EXECUTING" and giteaUp and prState(s, prKeyOf(s)) == "open")
// A3 step 4: the milestone's PR is open, so it was published and only
// PR_OPEN went unrecorded. Adopt it; nothing watched it, so from 0.
{ ...s0, st: "PR_OPEN", prOn: true, prKey: prKeyOf(s), prLast: 0,
prWatch: { on: true, floor: 0 }, planWatch: OFF }
else if (s.st == "PR_OPEN")
{ ...s0, prWatch: if (s.prOn) { on: true, floor: s.prLast } else OFF }
else if (s.st == "IDLE" or (SIGNOFF.contains(s.st) and s.gate.on))
@@ -278,11 +288,11 @@ module orchestrator {
boardOn: false, boardPosted: false, boardLast: 0,
intents: Set(),
planThread: List(), prThread: List(), nextId: 1,
giteaPr: Map(), openIssues: Set(),
giteaPr: Map(), opened: Map(), openIssues: Set(),
queue: List(), work: "none", workGate: "", workCid: 0, pubStage: 0, cancel: false,
planWatch: OFF, prWatch: OFF,
shown: NO_PLAN, taken: Set(), answered: Set(),
badApproval: false, staleFeedback: false, dupPublish: false,
badApproval: false, staleFeedback: false,
}
// Each action has a deterministic form taking its choices as parameters,
@@ -372,8 +382,9 @@ module orchestrator {
// Publishing, one step at a time, so a crash can land between them
// (MilestonePublisher.publish and control._publish_milestone):
// 0 commit, begin_intent; 1 push, open the PR; 2 commit_intent;
// 3 record the PR, watch it, PR_OPEN.
// 0 commit, begin_intent; 1 push, then adopt an open PR for the branch or
// open one ("lost": Gitea opened it but the response never came back);
// 2 commit_intent; 3 record the PR, watch it, PR_OPEN.
action publishStep(outcome: str): bool = {
val key = prKeyOf(s)
all {
@@ -389,10 +400,11 @@ module orchestrator {
else if (outcome == "fail" or outcome == "nochanges")
awaitInput(endWork(s), { kind: "milestone_stopped", g: "", interrupted: "" })
else if (prState(s, key) == "open")
// Gitea refuses a second PR for the branch: a GiteaError.
awaitInput(endWork({ ...s, dupPublish: true }),
{ ...s, pubStage: 2 } // adopted, never a second
else if (outcome == "lost")
awaitInput(endWork(openPr(s, key)),
{ kind: "milestone_stopped", g: "", interrupted: "" })
else { ...s, giteaPr: s.giteaPr.put(key, "open"), pubStage: 2 }
else { ...openPr(s, key), pubStage: 2 }
else if (s.pubStage == 2)
{ ...s, intents: s.intents.exclude(Set(key)), pubStage: 3 }
else
@@ -423,7 +435,7 @@ module orchestrator {
nondet up = Set(true, false).oneOf()
nondet planOutcome = Set("submit", "ask", "error", "stopped", "exception").oneOf()
nondet workOutcome = Set("completed", "stopped", "escalate", "exception").oneOf()
nondet pubOutcome = Set("ok", "nochanges", "fail", "exception").oneOf()
nondet pubOutcome = Set("ok", "nochanges", "fail", "lost", "exception").oneOf()
any {
send(kind), commentPlan(ckind), commentPr, pollPlan, pollPr, runJob(up),
finishPlanning(planOutcome, up), finishExec(workOutcome), publishStep(pubOutcome),
@@ -449,8 +461,8 @@ module orchestrator {
// Rejection feedback only ever lands on the plan it was written about.
val feedback_only_on_what_was_seen = not(s.staleFeedback)
// Never a second attempt to open a PR for a milestone that has one.
val one_pr_per_milestone = not(s.dupPublish)
// Never more than one PR opened for a milestone.
val one_pr_per_milestone = s.opened.keys().forall(k => s.opened.get(k) <= 1)
// A review comment the agent took up is answered, or is being worked on.
val review_comments_answered =
+19 -8
View File
@@ -1,7 +1,8 @@
// Counterexamples found in orchestrator.qnt, written out as short traces.
// Each ends by expecting the property it breaks to be false; once the design
// is fixed, the expectation is flipped and the trace kept as a regression.
// Fixed: 1-4 (replies bind to the revision up when they arrived).
// Fixed: 1-4 (replies bind to the revision up when they arrived), 5 (boot
// and publishing adopt an open PR).
//
// Run: quint test specs/orchestrator_scenarios.qnt (runs every *Test)
module orchestrator_scenarios {
@@ -77,22 +78,32 @@ module orchestrator_scenarios {
.expect(s.st == "AWAIT_MILESTONE_SIGNOFF" and feedback_only_on_what_was_seen)
// 5. A crash after the PR is opened but before PR_OPEN is recorded.
// Boot finds the intent's PR and marks it done, but the state still
// says EXECUTING, so it asks the restart question. 'continue' returns
// to the approved plan, and starting it publishes again: Gitea refuses
// a second PR for the branch, and there is no path back to PR_OPEN.
// Boot found the intent's PR and marked it done, but the state still
// said EXECUTING, so it asked the restart question; 'continue' and
// 'start' then published again, which Gitea refused, with no path back
// to PR_OPEN. Fixed: boot sees the open PR and adopts it.
run crashAfterOpeningThePrPublishesAgainTest =
toMilestoneApproved
.then(send("start")).then(runJob(true))
.then(finishExec("completed"))
.then(publishStep("ok")) // begin_intent
.then(publishStep("ok")) // push, open the PR
.then(crash(true)) // intent verified and committed; state EXECUTING
.then(send("continue")).then(runJob(true))
.then(crash(true)) // adopted on boot
.expect(s.st == "PR_OPEN" and s.prOn and one_pr_per_milestone)
// 5b. Gitea opens the PR but the response is lost; the operator starts
// the milestone again. Publishing adopts the open PR.
run lostResponseThenRetryAdoptsThePrTest =
toMilestoneApproved
.then(send("start")).then(runJob(true))
.then(finishExec("completed"))
.then(publishStep("ok")).then(publishStep("lost"))
.then(send("continue")).then(runJob(true)) // back to the approved plan
.then(send("start")).then(runJob(true))
.then(finishExec("completed"))
.then(publishStep("ok")).then(publishStep("ok"))
.expect(s.st == "AWAIT_INPUT" and not(one_pr_per_milestone))
.then(publishStep("ok")).then(publishStep("ok"))
.expect(s.st == "PR_OPEN" and one_pr_per_milestone)
// 6. A crash while the executor answers a review comment. The comment's
// cursor was saved before the work started (at most once), and a