CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s
Fixes counterexamples 1-4 from specs/orchestrator.qnt: a repeated 'approve', 'approve rev-1' meant for the architecture, approving on both channels, and 'approve' plus an afterthought all landed on the next plan, one the operator had not seen. - Revisions are numbered across the work item (A4 already required monotonic tags; the code numbered per gate), so rev-N names one plan. - Each reply is stamped on arrival with the revision that was up: in Matrix the one whose approval request had been sent (a plain value the transport thread reads, restored on boot), in Gitea the latest plan comment older than the reply (board.posts). - Gate.evaluate applies a reply that names no revision only if its stamp is the current revision. Otherwise an approval is not applied and feedback rejects nothing (it is still in the transcript), and the agent says which revision is up. 'approve rev-N' binds by its number. - Spec A4 states both rules. The model applies them; scenarios 1-4 now expect the properties to hold, and both held over 200,000 random traces and Apalache's exhaustive check to depth 8. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
114 lines
5.4 KiB
Plaintext
114 lines
5.4 KiB
Plaintext
// 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).
|
|
//
|
|
// Run: quint test specs/orchestrator_scenarios.qnt (runs every *Test)
|
|
module orchestrator_scenarios {
|
|
import orchestrator.* from "./orchestrator"
|
|
|
|
// Architecture plan rev-1 is up for sign-off, posted to Gitea.
|
|
action toArchSignoff = init
|
|
.then(send("use")).then(runJob(true))
|
|
.then(finishPlanning("submit", true))
|
|
|
|
// The milestone plan is approved; the next word starts it.
|
|
action toMilestoneApproved = toArchSignoff
|
|
.then(send("approve")).then(runJob(true))
|
|
.then(finishPlanning("submit", true))
|
|
.then(send("approve")).then(runJob(true))
|
|
|
|
action toPrOpen = toMilestoneApproved
|
|
.then(send("start")).then(runJob(true))
|
|
.then(finishExec("completed"))
|
|
.then(publishStep("ok")).then(publishStep("ok"))
|
|
.then(publishStep("ok")).then(publishStep("ok"))
|
|
|
|
// 1. The operator sends "approve" twice, say a double tap or an impatient
|
|
// repeat, while looking at the architecture plan. Both are queued
|
|
// before either runs. The first approves the architecture; planning
|
|
// the milestone runs; the second then approved the milestone plan,
|
|
// which the operator had not seen. Fixed: it is about rev-1, not
|
|
// applied.
|
|
run doubleApproveApprovesAnUnseenPlanTest =
|
|
toArchSignoff
|
|
.then(send("approve")).then(send("approve"))
|
|
.then(runJob(true)) // approves arch rev-1
|
|
.then(finishPlanning("submit", true)) // milestone-1 rev-1 is up
|
|
.then(runJob(true)) // the second "approve"
|
|
.expect(s.gate.g == "ms1" and not(s.gate.approved) and approval_only_of_what_was_seen)
|
|
|
|
// 2. The same with the revision named: "approve rev-1" meant for the
|
|
// architecture's rev-1 also matched the milestone's rev-1, because
|
|
// revisions were numbered per gate. Fixed: numbered across the task,
|
|
// the milestone plan is rev-2.
|
|
run namedRevisionApprovesTheWrongGateTest =
|
|
toArchSignoff
|
|
.then(send("approve")).then(send("approveRev"))
|
|
.then(runJob(true))
|
|
.then(finishPlanning("submit", true))
|
|
.then(runJob(true))
|
|
.expect(s.gate.rev == 2 and not(s.gate.approved) and approval_only_of_what_was_seen)
|
|
|
|
// 3. Approval on both channels: the operator approves in Matrix and on
|
|
// the Gitea issue. The Gitea approval is delivered and queued behind
|
|
// the Matrix one, which approves the architecture and posts the
|
|
// milestone plan. Re-attaching the plan watch does not recall a job
|
|
// already queued, so the Gitea approval approved the milestone plan.
|
|
// Fixed: it is about the plan posted before it, arch rev-1.
|
|
run approvalOnBothChannelsApprovesTheNextPlanTest =
|
|
toArchSignoff
|
|
.then(send("approve")).then(commentPlan("approve")).then(pollPlan)
|
|
.then(runJob(true))
|
|
.then(finishPlanning("submit", true))
|
|
.then(runJob(true))
|
|
.expect(s.gate.g == "ms1" and not(s.gate.approved) and approval_only_of_what_was_seen)
|
|
|
|
// 4. An afterthought becomes a rejection: "approve", then "also, keep it
|
|
// in one file", sent together. The note landed on the milestone plan
|
|
// as rejection feedback and it was planned again. Fixed: recorded,
|
|
// but it rejects nothing.
|
|
run anAfterthoughtRejectsTheNextPlanTest =
|
|
toArchSignoff
|
|
.then(send("approve")).then(send("text"))
|
|
.then(runJob(true))
|
|
.then(finishPlanning("submit", true))
|
|
.then(runJob(true))
|
|
.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.
|
|
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(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))
|
|
|
|
// 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.
|
|
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))
|
|
|
|
// 7. A completed task leaves its plan issue open: 'project complete'
|
|
// closes nothing, unlike 'abandon'.
|
|
run projectCompleteLeavesThePlanIssueOpenTest =
|
|
toPrOpen
|
|
.then(send("complete")).then(runJob(true))
|
|
.expect(s.st == "IDLE" and not(no_open_issue_without_a_task))
|
|
}
|