Bind approvals and feedback to the revision they were about
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
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-09-29 19:13:20 +00:00
1 parent 6c83839f4d
commit 97863c1939
10 files changed
+324 -44

No files matched your search

+15 -9
View File
@@ -55,7 +55,8 @@ module orchestrator {
tid: int, // task counter; a task's branches are its own
task: bool, // a project is in use
gate: Gate,
revs: str -> int,
revs: str -> int, // latest revision per gate
lastRev: int, // revisions run across the task (A4)
ms: int,
pending: Pending,
prOn: bool, prKey: int, prLast: int, // _data["pr"]
@@ -120,7 +121,7 @@ module orchestrator {
val t = s.tid + 1
startPlanning(
{ ...s, tid: t, task: true, gate: NO_GATE,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), ms: 1, pending: NO_PENDING,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), lastRev: 0, ms: 1, pending: NO_PENDING,
prOn: false, prKey: 0, prLast: 0,
boardOn: giteaUp, boardPosted: false, boardLast: 0,
openIssues: if (giteaUp) s.openIssues.union(Set(t)) else s.openIssues,
@@ -132,10 +133,10 @@ module orchestrator {
// skips what is already there).
pure def planSubmitted(s: Sys, giteaUp: bool): Sys =
val g = s.workGate
val rev = s.revs.get(g) + 1
val rev = s.lastRev + 1
val posted = s.boardOn and giteaUp
val t = endWork({ ...s,
revs: s.revs.set(g, rev),
revs: s.revs.set(g, rev), lastRev: rev,
gate: { on: true, g: g, rev: rev, approved: false },
st: if (g == "arch") "AWAIT_ARCH_SIGNOFF" else "AWAIT_MILESTONE_SIGNOFF",
shown: { g: g, rev: rev } })
@@ -146,19 +147,24 @@ module orchestrator {
// _execute_milestone: start
pure def startExec(s: Sys): Sys = { ...s, st: "EXECUTING", work: "exec" }
// _apply_gate_reply, for Matrix and Gitea alike.
// _apply_gate_reply and Gate.evaluate, for Matrix and Gitea alike.
// A job's `saw` doubles as its arrival stamp: the revision up when it was
// sent (Matrix) or the latest plan posted before it (Gitea). A reply that
// names no revision applies only if that is the current one.
pure def applyGate(s: Sys, j: Job): Sys =
val current = { g: s.gate.g, rev: s.gate.rev }
if (s.gate.approved)
if (j.kind == "start") startExec(s) else s
else if (j.kind == "approve" or (j.kind == "approveRev" and j.saw.rev == s.gate.rev))
else if (j.kind == "approveRev" and j.saw.rev != s.gate.rev)
s // names another revision: ignored with a notice
else if (j.kind != "approveRev" and j.saw.rev != s.gate.rev)
s // about another revision: not applied, the agent says so
else if (j.kind == "approve" or j.kind == "approveRev")
val s2 = { ...s, badApproval: s.badApproval or j.saw != current }
if (s.gate.g == "arch")
startPlanning({ ...s2, gate: NO_GATE }, msGate(s.ms))
else
{ ...s2, gate: { ...s.gate, approved: true }, st: "AWAIT_MILESTONE_SIGNOFF" }
else if (j.kind == "approveRev")
s // names another revision: ignored with a notice
else
// Anything else is rejection feedback, and the gate is planned again.
startPlanning({ ...s, staleFeedback: s.staleFeedback or j.saw != current, gate: NO_GATE },
@@ -267,7 +273,7 @@ module orchestrator {
action init = s' = {
st: "IDLE", tid: 0, task: false, gate: NO_GATE,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), ms: 1, pending: NO_PENDING,
revs: Map("arch" -> 0, "ms1" -> 0, "ms2" -> 0), lastRev: 0, ms: 1, pending: NO_PENDING,
prOn: false, prKey: 0, prLast: 0,
boardOn: false, boardPosted: false, boardLast: 0,
intents: Set(),