CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
489 lines
22 KiB
Plaintext
489 lines
22 KiB
Plaintext
// A model of nisse-dev's orchestrator (nisse-dev/src/nisse_dev/control.py),
|
|
// written to look for counterexamples: interleavings of operator messages,
|
|
// Gitea comments, work outcomes and crashes that break a property the
|
|
// design relies on.
|
|
//
|
|
// Everything lives in one record, `s`, and each handler is a pure function
|
|
// named after the method it mirrors. The model keeps what matters for the
|
|
// properties below and drops the rest (texts, transcripts, models, tools).
|
|
//
|
|
// Durable state (the state store) survives a crash; volatile state (the
|
|
// worker queue, work in flight, pollers, the cancel flag) does not. Gitea is
|
|
// the outside world and survives everything.
|
|
//
|
|
// Run: quint run specs/orchestrator.qnt --invariant=all_properties
|
|
// quint run specs/orchestrator.qnt --invariant=<one property>
|
|
module orchestrator {
|
|
|
|
// ---- bounds --------------------------------------------------------------
|
|
|
|
pure val MAX_MS = 2 // milestones per task
|
|
pure val MAX_TASKS = 2 // tasks per run ('use' / 'abandon' / 'complete')
|
|
pure val MAX_QUEUE = 3 // jobs waiting on the worker
|
|
pure val MAX_IDS = 8 // Gitea comments per run
|
|
|
|
// ---- vocabulary ----------------------------------------------------------
|
|
|
|
pure val WORKING = Set("PLANNING_ARCH", "MILESTONE_PLANNING", "EXECUTING")
|
|
pure val SIGNOFF = Set("AWAIT_ARCH_SIGNOFF", "AWAIT_MILESTONE_SIGNOFF")
|
|
// Work that sets _working, so a message then gets "busy" (control._handle).
|
|
pure val LONG_WORK = Set("plan", "exec", "review")
|
|
|
|
// What the operator had in front of them: the plan last put up for sign-off.
|
|
type Plan = { g: str, rev: int }
|
|
pure val NO_PLAN: Plan = { g: "", rev: 0 }
|
|
|
|
type Gate = { on: bool, g: str, rev: int, approved: bool }
|
|
pure val NO_GATE: Gate = { on: false, g: "", rev: 0, approved: false }
|
|
|
|
type Pending = { kind: str, g: str, interrupted: str }
|
|
pure val NO_PENDING: Pending = { kind: "", g: "", interrupted: "" }
|
|
|
|
// A message or comment waiting on the worker. `saw` is what the operator
|
|
// had seen when they sent it; the agent never knows it, the properties do.
|
|
type Job = { src: str, kind: str, saw: Plan, cid: int }
|
|
|
|
type PlanComment = { id: int, tid: int, kind: str, saw: Plan }
|
|
type PrComment = { id: int, key: int }
|
|
|
|
type Watch = { on: bool, floor: int }
|
|
pure val OFF: Watch = { on: false, floor: 0 }
|
|
|
|
type Sys = {
|
|
// -- durable: the state store (control._data and the state) --
|
|
st: str,
|
|
tid: int, // task counter; a task's branches are its own
|
|
task: bool, // a project is in use
|
|
gate: Gate,
|
|
revs: str -> int, // latest revision per gate
|
|
lastRev: int, // revisions run across the task (L3)
|
|
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 --
|
|
planThread: List[PlanComment],
|
|
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],
|
|
work: str, // none / plan / exec / publish / review
|
|
workGate: str,
|
|
workCid: int,
|
|
pubStage: int,
|
|
cancel: bool,
|
|
planWatch: Watch,
|
|
prWatch: Watch,
|
|
// -- the operator's view, and history the properties read --
|
|
shown: Plan,
|
|
taken: Set[int], // review comments the agent took up
|
|
answered: Set[int], // ... and answered (pushed, asked, or reported)
|
|
badApproval: bool,
|
|
staleFeedback: bool,
|
|
}
|
|
|
|
// ---- helpers ------------------------------------------------------------
|
|
|
|
pure def msGate(ms: int): str = if (ms == 1) "ms1" else "ms2"
|
|
pure def prKeyOf(s: Sys): int = s.tid * 10 + s.ms
|
|
pure def prState(s: Sys, key: int): str =
|
|
if (s.giteaPr.keys().contains(key)) s.giteaPr.get(key) else "none"
|
|
pure def planIds(s: Sys): int =
|
|
s.planThread.foldl(0, (acc, c) => if (c.tid == s.tid and c.id > acc) c.id else acc)
|
|
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
|
|
pure def awaitInput(s: Sys, p: Pending): Sys = { ...s, st: "AWAIT_INPUT", pending: p }
|
|
|
|
// _settled: a job that leaves a working state behind with nothing running
|
|
// moves to AWAIT_INPUT with the restart choice.
|
|
pure def settle(s: Sys): Sys =
|
|
if (s.work == "none" and WORKING.contains(s.st))
|
|
awaitInput(s, { kind: "boot", g: "", interrupted: s.st })
|
|
else s
|
|
|
|
pure def endWork(s: Sys): Sys = { ...s, work: "none", workGate: "", workCid: 0, cancel: false }
|
|
|
|
// _run_planning_turn, first half: the turn is now in flight.
|
|
pure def startPlanning(s: Sys, g: str): Sys =
|
|
{ ...s, st: if (g == "arch") "PLANNING_ARCH" else "MILESTONE_PLANNING",
|
|
work: "plan", workGate: g }
|
|
|
|
// _begin_project
|
|
pure def beginProject(s: Sys, giteaUp: bool): Sys =
|
|
val t = s.tid + 1
|
|
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, reviewing: 0,
|
|
boardOn: giteaUp, boardPosted: false, boardLast: 0,
|
|
openIssues: if (giteaUp) s.openIssues.union(Set(t)) else s.openIssues,
|
|
planWatch: OFF, prWatch: OFF },
|
|
"arch")
|
|
|
|
// _run_planning_turn, second half: the planner submitted a plan.
|
|
// _post_plan_to_board then watches the issue from here (a fresh watch
|
|
// skips what is already there).
|
|
pure def planSubmitted(s: Sys, giteaUp: bool): Sys =
|
|
val g = s.workGate
|
|
val rev = s.lastRev + 1
|
|
val posted = s.boardOn and giteaUp
|
|
val t = endWork({ ...s,
|
|
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 } })
|
|
if (posted)
|
|
{ ...t, boardPosted: true, boardLast: planIds(t), planWatch: { on: true, floor: planIds(t) } }
|
|
else t
|
|
|
|
// _execute_milestone: start
|
|
pure def startExec(s: Sys): Sys = { ...s, st: "EXECUTING", work: "exec" }
|
|
|
|
// _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 == "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
|
|
// Anything else is rejection feedback, and the gate is planned again.
|
|
startPlanning({ ...s, staleFeedback: s.staleFeedback or j.saw != current, gate: NO_GATE },
|
|
s.gate.g)
|
|
|
|
// _return_to_pr_open / _watch_pr
|
|
pure def returnToPrOpen(s: Sys): Sys =
|
|
{ ...s, st: "PR_OPEN", prWatch: if (s.prOn) { on: true, floor: s.prLast } else OFF }
|
|
|
|
// _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, 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' 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, 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 =
|
|
if (j.kind != "continue" and j.kind != "replan")
|
|
{ ...s, pending: p } // asked again
|
|
else if (p.interrupted == "PR_OPEN")
|
|
returnToPrOpen(s)
|
|
else if (j.kind == "continue" and p.interrupted == "EXECUTING" and s.gate.approved)
|
|
{ ...s, st: "AWAIT_MILESTONE_SIGNOFF" }
|
|
else
|
|
val g =
|
|
if (p.interrupted == "PLANNING_ARCH") "arch"
|
|
else if (p.interrupted == "MILESTONE_PLANNING" or p.interrupted == "EXECUTING") msGate(s.ms)
|
|
else if (s.gate.g != "") s.gate.g
|
|
else if (s.revs.get("arch") == 0) "arch"
|
|
else msGate(s.ms)
|
|
startPlanning({ ...s, gate: NO_GATE }, g)
|
|
|
|
// _handle_answer
|
|
pure def answer(s: Sys, j: Job, giteaUp: bool): Sys =
|
|
val p = s.pending
|
|
val s0 = { ...s, pending: NO_PENDING }
|
|
if (p.kind == "")
|
|
if (j.kind == "use") beginProject({ ...s, planWatch: OFF, prWatch: OFF }, giteaUp) else s
|
|
else if (p.kind == "question" or p.kind == "planning_failed") startPlanning(s0, p.g)
|
|
else if (p.kind == "review_stopped") returnToPrOpen(s0)
|
|
else if (p.kind == "milestone_stopped") { ...s0, st: "AWAIT_MILESTONE_SIGNOFF" }
|
|
else if (p.kind == "boot") resume(s0, p, j)
|
|
else s0
|
|
|
|
// _handle_now, for a Matrix message the worker has reached.
|
|
pure def handleMatrix(s: Sys, j: Job, giteaUp: bool): Sys =
|
|
if (j.kind == "abandon")
|
|
if (s.st == "IDLE") s else abandon(s)
|
|
else if (s.st == "IDLE")
|
|
if (j.kind == "use") beginProject(s, giteaUp) else s
|
|
else if (SIGNOFF.contains(s.st))
|
|
if (not(s.gate.on))
|
|
awaitInput(s, { kind: "boot", g: "",
|
|
interrupted: if (s.st == "AWAIT_ARCH_SIGNOFF") "PLANNING_ARCH" else "MILESTONE_PLANNING" })
|
|
else applyGate(s, j)
|
|
else if (s.st == "PR_OPEN")
|
|
if (j.kind == "next" and s.ms < MAX_MS)
|
|
startPlanning({ ...s, ms: s.ms + 1, gate: NO_GATE, prOn: false, prWatch: OFF }, msGate(s.ms + 1))
|
|
else if (j.kind == "complete") complete(s)
|
|
else s
|
|
else if (s.st == "AWAIT_INPUT") answer(s, j, giteaUp)
|
|
else s // busy
|
|
|
|
// _handle_plan_comment_now
|
|
pure def handlePlanComment(s: Sys, j: Job): Sys =
|
|
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,
|
|
// 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)),
|
|
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)
|
|
else if (j.src == "plan") handlePlanComment(s, j)
|
|
else handleReviewComment(s, j)
|
|
|
|
// Orchestrator.start, after a crash: reconcile intents against Gitea,
|
|
// then resume, re-ask, or ask the restart question.
|
|
pure def boot(s: Sys, giteaUp: bool): Sys =
|
|
val unresolved = s.intents.size() > 0 and not(giteaUp)
|
|
val s0 = { ...s, intents: if (giteaUp) Set() else s.intents }
|
|
if (unresolved)
|
|
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")
|
|
// L8 boot step 3: 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")
|
|
// 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
|
|
awaitInput(s0, { kind: "boot", g: "", interrupted: s.st })
|
|
|
|
// ---- the system ---------------------------------------------------------
|
|
|
|
var s: Sys
|
|
|
|
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, reviewing: 0,
|
|
boardOn: false, boardPosted: false, boardLast: 0,
|
|
intents: Set(),
|
|
planThread: List(), prThread: List(), nextId: 1,
|
|
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,
|
|
}
|
|
|
|
// Each action has a deterministic form taking its choices as parameters,
|
|
// so scenarios (orchestrator_scenarios.qnt) can replay a trace exactly;
|
|
// `step` picks the parameters at random.
|
|
|
|
pure val MATRIX_KINDS = Set("use", "approve", "approveRev", "text", "start", "next",
|
|
"complete", "abandon", "continue", "replan", "stop")
|
|
pure val COMMENT_KINDS = Set("approve", "approveRev", "text", "start")
|
|
|
|
// The operator sends a Matrix message (control._handle, on the
|
|
// transport's thread). While long work runs it only gets "busy"; `stop`
|
|
// reaches the cancel flag whenever the worker is busy.
|
|
action send(kind: str): bool = all {
|
|
s.tid < MAX_TASKS or kind != "use",
|
|
if (kind == "stop")
|
|
s' = { ...s, cancel: s.cancel or s.work != "none" }
|
|
else if (LONG_WORK.contains(s.work))
|
|
s' = s
|
|
else all {
|
|
s.queue.length() < MAX_QUEUE,
|
|
s' = { ...s, queue: s.queue.append({ src: "matrix", kind: kind, saw: s.shown, cid: 0 }) },
|
|
},
|
|
}
|
|
|
|
// The operator comments on the plan issue.
|
|
action commentPlan(kind: str): bool = all {
|
|
s.task, s.openIssues.contains(s.tid), s.nextId <= MAX_IDS,
|
|
s' = { ...s, nextId: s.nextId + 1,
|
|
planThread: s.planThread.append({ id: s.nextId, tid: s.tid, kind: kind, saw: s.shown }) },
|
|
}
|
|
|
|
// The operator comments on the open PR.
|
|
action commentPr: bool = all {
|
|
s.prOn, s.nextId <= MAX_IDS, prState(s, s.prKey) == "open",
|
|
s' = { ...s, nextId: s.nextId + 1, prThread: s.prThread.append({ id: s.nextId, key: s.prKey }) },
|
|
}
|
|
|
|
// A poller delivers the next new comment (CommentPoller.poll_once). It
|
|
// waits on the worker, so one job per poller at a time.
|
|
action pollPlan: bool = {
|
|
val c = s.planThread.foldl({ id: 0, tid: 0, kind: "", saw: NO_PLAN },
|
|
(acc, x) => if (x.tid == s.tid and x.id > s.planWatch.floor and (acc.id == 0 or x.id < acc.id)) x else acc)
|
|
all {
|
|
s.planWatch.on, c.id > 0, not(queued(s, "plan")), s.queue.length() < MAX_QUEUE,
|
|
s' = { ...s, planWatch: { on: true, floor: c.id },
|
|
queue: s.queue.append({ src: "plan", kind: c.kind, saw: c.saw, cid: c.id }) },
|
|
}
|
|
}
|
|
|
|
action pollPr: bool = {
|
|
val c = s.prThread.foldl({ id: 0, key: 0 },
|
|
(acc, x) => if (x.key == s.prKey and x.id > s.prWatch.floor and (acc.id == 0 or x.id < acc.id)) x else acc)
|
|
all {
|
|
s.prWatch.on, c.id > 0, not(queued(s, "pr")), s.queue.length() < MAX_QUEUE,
|
|
s' = { ...s, prWatch: { on: true, floor: c.id },
|
|
queue: s.queue.append({ src: "pr", kind: "review", saw: NO_PLAN, cid: c.id }) },
|
|
}
|
|
}
|
|
|
|
// The worker takes the next job, one at a time, and settles it.
|
|
action runJob(giteaUp: bool): bool = all {
|
|
s.work == "none", s.queue.length() > 0,
|
|
s' = settle(handleJob({ ...s, queue: s.queue.tail() }, s.queue.head(), giteaUp)),
|
|
}
|
|
|
|
// A planning turn ends.
|
|
action finishPlanning(outcome: str, giteaUp: bool): bool = all {
|
|
s.work == "plan", outcome != "stopped" or s.cancel,
|
|
s' =
|
|
if (outcome == "submit") planSubmitted(s, giteaUp)
|
|
else if (outcome == "ask" or outcome == "stopped")
|
|
awaitInput(endWork(s), { kind: "question", g: s.workGate, interrupted: "" })
|
|
else if (outcome == "error")
|
|
awaitInput(endWork(s), { kind: "planning_failed", g: s.workGate, interrupted: "" })
|
|
else settle(endWork(s)),
|
|
}
|
|
|
|
// The executor finishes a milestone.
|
|
action finishExec(outcome: str): bool = all {
|
|
s.work == "exec", outcome != "stopped" or s.cancel,
|
|
s' =
|
|
if (outcome == "completed") { ...s, work: "publish", pubStage: 0, cancel: false }
|
|
else if (outcome == "exception") settle(endWork(s))
|
|
else awaitInput(endWork(s), { kind: "milestone_stopped", g: "", interrupted: "" }),
|
|
}
|
|
|
|
// 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, 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 {
|
|
s.work == "publish",
|
|
s' =
|
|
if (s.pubStage == 0)
|
|
if (outcome == "nochanges" or outcome == "fail")
|
|
awaitInput(endWork(s), { kind: "milestone_stopped", g: "", interrupted: "" })
|
|
else if (outcome == "exception") settle(endWork(s))
|
|
else { ...s, intents: s.intents.union(Set(key)), pubStage: 1 }
|
|
else if (s.pubStage == 1)
|
|
if (outcome == "exception") settle(endWork(s))
|
|
else if (outcome == "fail" or outcome == "nochanges")
|
|
awaitInput(endWork(s), { kind: "milestone_stopped", g: "", interrupted: "" })
|
|
else if (prState(s, key) == "open")
|
|
{ ...s, pubStage: 2 } // adopted, never a second
|
|
else if (outcome == "lost")
|
|
awaitInput(endWork(openPr(s, key)),
|
|
{ kind: "milestone_stopped", g: "", interrupted: "" })
|
|
else { ...openPr(s, key), pubStage: 2 }
|
|
else if (s.pubStage == 2)
|
|
{ ...s, intents: s.intents.exclude(Set(key)), pubStage: 3 }
|
|
else
|
|
endWork({ ...s, prOn: true, prKey: key, prLast: prIds(s, key),
|
|
prWatch: { on: true, floor: prIds(s, key) }, planWatch: OFF,
|
|
st: "PR_OPEN" }),
|
|
}
|
|
}
|
|
|
|
// The executor finishes responding to a review comment.
|
|
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)), reviewing: 0 })
|
|
if (outcome == "stopped" or outcome == "escalate")
|
|
awaitInput(t, { kind: "review_stopped", g: "", interrupted: "" })
|
|
else t,
|
|
}
|
|
|
|
// The process dies and starts again: volatile state is gone.
|
|
action crash(giteaUp: bool): bool =
|
|
s' = boot({ ...s, queue: List(), work: "none", workGate: "", workCid: 0, pubStage: 0,
|
|
cancel: false, planWatch: OFF, prWatch: OFF }, giteaUp)
|
|
|
|
action step = {
|
|
nondet kind = MATRIX_KINDS.oneOf()
|
|
nondet ckind = COMMENT_KINDS.oneOf()
|
|
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", "lost", "exception").oneOf()
|
|
any {
|
|
send(kind), commentPlan(ckind), commentPr, pollPlan, pollPr, runJob(up),
|
|
finishPlanning(planOutcome, up), finishExec(workOutcome), publishStep(pubOutcome),
|
|
finishReview(workOutcome), crash(up),
|
|
}
|
|
}
|
|
|
|
// ---- properties ---------------------------------------------------------
|
|
|
|
// #66: a working state only while its work is running.
|
|
val no_stranding = WORKING.contains(s.st) implies s.work != "none"
|
|
|
|
// Waiting on the operator always means a question to answer.
|
|
val await_input_has_a_question = s.st == "AWAIT_INPUT" implies s.pending.kind != ""
|
|
|
|
// A milestone runs only on an approved plan for that milestone.
|
|
val execution_needs_approval =
|
|
s.st == "EXECUTING" implies (s.gate.on and s.gate.approved and s.gate.g == msGate(s.ms))
|
|
|
|
// An approval only ever approves the plan the operator was looking at.
|
|
val approval_only_of_what_was_seen = not(s.badApproval)
|
|
|
|
// Rejection feedback only ever lands on the plan it was written about.
|
|
val feedback_only_on_what_was_seen = not(s.staleFeedback)
|
|
|
|
// 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, 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)
|
|
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
|
|
|
|
val all_properties = and {
|
|
no_stranding, await_input_has_a_question, execution_needs_approval,
|
|
approval_only_of_what_was_seen, feedback_only_on_what_was_seen,
|
|
one_pr_per_milestone, review_comments_answered, no_open_issue_without_a_task,
|
|
}
|
|
}
|