Redeliver an interrupted review comment, and close the issue on completion
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s

Fixes counterexamples 6 and 7 from specs/orchestrator.qnt, the last two.

6. A crash while the executor answered a review comment lost it: its
   cursor is saved before the work (at most once), and a restart in
   PR_OPEN resumed quietly. Now pr.reviewing marks the comment until
   the executor returns (every way it returns, but not a crash), and
   boot resumes the watch just before it, with a notice. The mark stays
   until it is answered, so a second crash cannot lose it either.
7. 'project complete' left the plan issue open. It now closes it with a
   note, after IDLE is saved, as abandon does (shared helper).

The model marks and redelivers, and closes the issue; scenarios 6 and 7
now expect their properties to hold. A mutant without redelivery fails
scenario 6. All eight properties held together over 300,000 traces.
No xfail reproductions remain.

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 20:30:14 +00:00
1 parent 83a4bd72b5
commit 848bd9f122
4 files changed
+153 -52

No files matched your search

+20 -11
View File
@@ -60,6 +60,7 @@ module orchestrator {
ms: int,
pending: Pending,
prOn: bool, prKey: int, prLast: int, // _data["pr"]
reviewing: int, // pr["reviewing"]: the comment being answered, 0 if none
boardOn: bool, boardPosted: bool, boardLast: int, // _data["board"]
intents: Set[int], // pending open_pr intents, by PR key
// -- Gitea --
@@ -127,7 +128,7 @@ module orchestrator {
startPlanning(
{ ...s, tid: t, task: true, gate: NO_GATE,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), lastRev: 0, ms: 1, pending: NO_PENDING,
prOn: false, prKey: 0, prLast: 0,
prOn: false, prKey: 0, prLast: 0, reviewing: 0,
boardOn: giteaUp, boardPosted: false, boardLast: 0,
openIssues: if (giteaUp) s.openIssues.union(Set(t)) else s.openIssues,
planWatch: OFF, prWatch: OFF },
@@ -182,15 +183,17 @@ module orchestrator {
// _abandon: IDLE first, then close the issue and the PR, abort intents.
pure def abandon(s: Sys): Sys =
{ ...s, st: "IDLE", task: false, gate: NO_GATE, pending: NO_PENDING,
prOn: false, boardOn: false, boardPosted: false, intents: Set(),
prOn: false, reviewing: 0, boardOn: false, boardPosted: false, intents: Set(),
openIssues: s.openIssues.exclude(Set(s.tid)),
giteaPr: if (s.prOn) s.giteaPr.put(s.prKey, "closed") else s.giteaPr,
planWatch: OFF, prWatch: OFF }
// _handle_pr_open: 'project complete'
// _handle_pr_open: 'project complete' closes the plan issue; the PR is
// the operator's to merge.
pure def complete(s: Sys): Sys =
{ ...s, st: "IDLE", task: false, gate: NO_GATE, pending: NO_PENDING,
prOn: false, boardOn: false, boardPosted: false, planWatch: OFF, prWatch: OFF }
prOn: false, reviewing: 0, boardOn: false, boardPosted: false,
openIssues: s.openIssues.exclude(Set(s.tid)), planWatch: OFF, prWatch: OFF }
// _resume_after_restart
pure def resume(s: Sys, p: Pending, j: Job): Sys =
@@ -245,11 +248,12 @@ module orchestrator {
if (not(SIGNOFF.contains(s.st))) s // not taken
else applyGate({ ...s, boardLast: max(s.boardLast, j.cid) }, j)
// _handle_review_comment_now, first half: the executor starts on it.
// _handle_review_comment_now, first half: the executor starts on it,
// with the comment marked (durably) until it returns.
pure def handleReviewComment(s: Sys, j: Job): Sys =
if (s.st != "PR_OPEN") s // not taken
else { ...s, prLast: max(s.prLast, j.cid), taken: s.taken.union(Set(j.cid)),
work: "review", workCid: j.cid }
reviewing: j.cid, work: "review", workCid: j.cid }
pure def handleJob(s: Sys, j: Job, giteaUp: bool): Sys =
if (j.src == "matrix") handleMatrix(s, j, giteaUp)
@@ -271,7 +275,10 @@ module orchestrator {
{ ...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 }
// A comment whose answer never finished is delivered again.
{ ...s0, prWatch: if (s.prOn)
{ on: true, floor: if (s.reviewing > 0) s.reviewing - 1 else s.prLast }
else OFF }
else if (s.st == "IDLE" or (SIGNOFF.contains(s.st) and s.gate.on))
if (s.gate.on and s.boardPosted) { ...s0, planWatch: { on: true, floor: s.boardLast } } else s0
else
@@ -284,7 +291,7 @@ module orchestrator {
action init = s' = {
st: "IDLE", tid: 0, task: false, gate: NO_GATE,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), lastRev: 0, ms: 1, pending: NO_PENDING,
prOn: false, prKey: 0, prLast: 0,
prOn: false, prKey: 0, prLast: 0, reviewing: 0,
boardOn: false, boardPosted: false, boardLast: 0,
intents: Set(),
planThread: List(), prThread: List(), nextId: 1,
@@ -418,7 +425,7 @@ module orchestrator {
action finishReview(outcome: str): bool = all {
s.work == "review", outcome != "stopped" or s.cancel,
s' =
val t = endWork({ ...s, answered: s.answered.union(Set(s.workCid)) })
val t = endWork({ ...s, answered: s.answered.union(Set(s.workCid)), reviewing: 0 })
if (outcome == "stopped" or outcome == "escalate")
awaitInput(t, { kind: "review_stopped", g: "", interrupted: "" })
else t,
@@ -464,9 +471,11 @@ module orchestrator {
// 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.
// A review comment the agent took up is answered, being worked on, or
// marked to be delivered again: never silently dropped.
val review_comments_answered =
s.taken.forall(id => s.answered.contains(id) or (s.work == "review" and s.workCid == id))
s.taken.forall(id => s.answered.contains(id) or (s.work == "review" and s.workCid == id)
or s.reviewing == id)
// With no task, no plan issue is left open.
val no_open_issue_without_a_task = not(s.task) implies s.openIssues.size() == 0
+9 -8
View File
@@ -1,8 +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), 5 (boot
// and publishing adopt an open PR).
// All fixed: 1-4 (replies bind to the revision up when they arrived), 5 (boot
// and publishing adopt an open PR), 6 (redelivered), 7 (issue closed).
//
// Run: quint test specs/orchestrator_scenarios.qnt (runs every *Test)
module orchestrator_scenarios {
@@ -107,18 +107,19 @@ module orchestrator_scenarios {
// 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
// restart in PR_OPEN resumes quietly, so the comment is never answered
// and nobody is told.
// restart in PR_OPEN resumed quietly, so it was never answered. Fixed:
// the comment is marked while answered, and delivered again.
run crashDuringAReviewLosesTheCommentTest =
toPrOpen
.then(commentPr).then(pollPr).then(runJob(true)) // the executor starts on it
.then(crash(true))
.expect(s.st == "PR_OPEN" and not(review_comments_answered))
.expect(s.st == "PR_OPEN" and review_comments_answered)
.then(pollPr).then(runJob(true)).then(finishReview("completed"))
.expect(s.answered.size() == 1 and s.reviewing == 0)
// 7. A completed task leaves its plan issue open: 'project complete'
// closes nothing, unlike 'abandon'.
// 7. 'project complete' left the plan issue open. Fixed: it closes it.
run projectCompleteLeavesThePlanIssueOpenTest =
toPrOpen
.then(send("complete")).then(runJob(true))
.expect(s.st == "IDLE" and not(no_open_issue_without_a_task))
.expect(s.st == "IDLE" and no_open_issue_without_a_task)
}