mesh-issues: read merged from the pull request, not the state, so a closed one is withdrawn (hq issue 483)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)

mesh-delivery says stopped for a pull request closed unmerged too; read as
merged, it was said stuck to the operator for good. Cite ADR 0284 for the
worker rules, which the comments gave as 0283.
This commit is contained in:
2026-10-11 12:24:45 +02:00
parent 601eed06e7
commit 3aaccba4e2
14 changed files with 88 additions and 49 deletions
@@ -523,7 +523,7 @@ func TestAnIssueIsOpenedNotedLinkedMovedAndListedOverTheBus(t *testing.T) {
var kinds []string
for _, e := range events {
kinds = append(kinds, e.Key)
// The move to fixing carries who works on it; every other change, who acted (ADR 0283 §4).
// The move to fixing carries who works on it; every other change, who acted (ADR 0284 §6).
want := Unproven
if e.Key == EventMoved && e.Body["to"] == StatusFixing {
want = "fix-365@g14"
@@ -56,7 +56,7 @@ func deliveredNotResolved(is *Issue) bool { return is.Status == StatusFixing &&
// deliveredAt is when the last of an issue's delivered fixes was delivered, as the delivery said it.
func deliveredAt(is *Issue) time.Time {
var last time.Time
for _, pr := range fixPullRequests(is) {
for _, pr := range liveFixes(is) {
if f := fixOf(is, pr); f != nil && contains(is.Delivered, pr) && f.Since.After(last) {
last = f.Since
}
@@ -81,7 +81,7 @@ func (t *Tracker) sayNotResolved(is *Issue, now time.Time) (bool, error) {
worker = is.Worker.By
}
ago := now.Sub(at).Round(time.Minute)
prs := strings.Join(fixPullRequests(is), ", ")
prs := strings.Join(liveFixes(is), ", ")
if err := t.Notify(notResolvedKey(is.Number), "warning",
fmt.Sprintf("issue %d: fixed and delivered, not resolved; %s works on it", is.Number, worker),
fmt.Sprintf("Fixed and delivered, not resolved (issue %d)", is.Number),
@@ -145,7 +145,7 @@ func (t *Tracker) askUnheard() error {
if since.IsZero() {
since = t.now()
}
if _, err := t.recordFix(pr, d.State, d.Why, afterMerge(d.State), since, n); err != nil {
if _, err := t.recordFix(pr, d.State, d.Why, d.Merged, since, n); err != nil {
return err
}
heard = true
@@ -160,9 +160,10 @@ func (t *Tracker) askUnheard() error {
}
// openPullRequest says whether an issue names a pull request not merged yet, or one whose delivery the tracker
// never heard of: work in review is work, and a fix unheard of is not taken for none.
// never heard of: work in review is work, and a fix unheard of is not taken for none. One closed without merging
// is not open.
func openPullRequest(is *Issue) bool {
for _, pr := range fixPullRequests(is) {
for _, pr := range liveFixes(is) {
f := fixOf(is, pr)
if f == nil || (!f.Merged && !contains(is.Delivered, pr)) {
return true
+45 -14
View File
@@ -44,13 +44,36 @@ func reached(state string) bool {
return state == "delivered" || state == "covered" || state == "retired"
}
// afterMerge says whether a state is one a delivery reaches only once merged.
func afterMerge(state string) bool {
switch state {
case "published", "delivering", "held", "delivered", "failed", "stopped", "covered", "retired":
return true
// Whether a pull request is merged is what the delivery says of the pull request itself — `merged_as` on a
// transition, the pull request's `merged` in checks — never read from the state: `stopped` is also what a pull
// request closed without merging becomes (mesh-delivery's `closed` event), and read as merged it was said stuck to
// the operator for good (novox/hq issue 483).
// withdrawn says whether a fix is a pull request closed without merging: no fix at all, neither awaiting delivery,
// delivered, nor open.
func withdrawn(f *Fix) bool { return f != nil && f.State == "stopped" && !f.Merged }
// liveFixes is an issue's fixes less those withdrawn: what its delivery, readiness to verify, the stuck sweep and a
// stale claim are measured on. fixPullRequests still names the withdrawn ones, so a pull request reopened is heard.
func liveFixes(is *Issue) []string {
var out []string
for _, pr := range fixPullRequests(is) {
if !withdrawn(fixOf(is, pr)) {
out = append(out, pr)
}
}
return false
return out
}
// withdrawnFixes is the pull requests of an issue closed without merging.
func withdrawnFixes(is *Issue) []string {
var out []string
for _, pr := range fixPullRequests(is) {
if withdrawn(fixOf(is, pr)) {
out = append(out, pr)
}
}
return out
}
func fixOf(is *Issue, pr string) *Fix {
@@ -64,7 +87,7 @@ func fixOf(is *Issue, pr string) *Fix {
// allDelivered says whether an issue names a pull request and every one is delivered.
func allDelivered(is *Issue) bool {
prs := fixPullRequests(is)
prs := liveFixes(is)
if len(prs) == 0 {
return false
}
@@ -79,8 +102,11 @@ func allDelivered(is *Issue) bool {
// DeliveryLine is the derived delivery state of an issue's fix, in words; empty when fixed-by names no pull
// request.
func DeliveryLine(is *Issue) string {
prs := fixPullRequests(is)
prs := liveFixes(is)
if len(prs) == 0 {
if w := withdrawnFixes(is); len(w) > 0 {
return "fix withdrawn: " + strings.Join(w, ", ") + " closed without merging"
}
return ""
}
var held, failed, merged, open, unheard []string
@@ -182,7 +208,7 @@ func (t *Tracker) afterFix(n uint64) error {
}
if allDelivered(is) {
if unresolved(is) {
prs := fixPullRequests(is)
prs := liveFixes(is)
sort.Strings(prs)
if strings.Join(is.VerifyFor, ",") != strings.Join(prs, ",") {
// The note first, kept once by its words; then the claim; then the event. A failure before the claim
@@ -245,7 +271,7 @@ func stillStuck(is *Issue) bool {
if is.Status == StatusWontfix {
return false
}
named := fixPullRequests(is)
named := liveFixes(is)
for _, f := range is.Fixes {
if f.StuckSaid && contains(named, f.PullRequest) && !contains(is.Delivered, f.PullRequest) {
return true
@@ -296,7 +322,7 @@ func (t *Tracker) OnTransition(e stdio.Envelope) error {
if at.IsZero() {
at = t.now()
}
merged := body.MergedAs != "" || afterMerge(body.To)
merged := body.MergedAs != ""
changed, err := t.recordFix(prKey(body.Repository, body.Number), body.To, body.Why, merged, at, 0)
if err != nil {
return err
@@ -315,6 +341,8 @@ type Delivery struct {
Why string
// Since is when the delivery entered its state, as it says; zero when it does not.
Since time.Time
// Merged is the pull request's own merged flag, as the forge says it.
Merged bool
}
// DeliveryState asks mesh-delivery for one pull request's delivery.
@@ -333,11 +361,14 @@ func busDeliveryState(owner string) DeliveryState {
Waits string `json:"waits"`
Since time.Time `json:"since"`
} `json:"delivery"`
Pull struct {
Merged bool `json:"merged"`
} `json:"pull"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return Delivery{}, fmt.Errorf("mesh-delivery's checks answered no delivery: %.200s", raw)
}
return Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC()}, nil
return Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC(), Merged: out.Pull.Merged}, nil
}
}
@@ -386,7 +417,7 @@ func (t *Tracker) OnMoved(e stdio.Envelope) error {
if since.IsZero() {
since = t.now()
}
if _, err := t.recordFix(pr, state, d.Why, afterMerge(state), since, body.Number); err != nil {
if _, err := t.recordFix(pr, state, d.Why, d.Merged, since, body.Number); err != nil {
return err
}
}
@@ -452,7 +483,7 @@ func (t *Tracker) Sweep() (int, error) {
if err := t.clearStuck(is); err != nil {
t.Logf("[mesh-issues] the stuck fix of %d could not be cleared on the operator's channel: %v", n, err)
}
named := fixPullRequests(is)
named := liveFixes(is)
for _, f := range is.Fixes {
if !contains(named, f.PullRequest) || contains(is.Delivered, f.PullRequest) || !f.Merged || f.MergedAt == nil ||
reached(f.State) || f.StuckSaid || now.Sub(*f.MergedAt) < bound {
+1 -1
View File
@@ -125,7 +125,7 @@ type Verdict struct {
func (v Verdict) closes() bool { return v.Verdict == VerdictDuplicate && v.Certain }
// unresolved says whether an issue may be offered, linked or noted by the triage: at open, diagnosing, located or
// fixing (ADR 0283 §5).
// fixing (ADR 0284 §7).
func unresolved(is *Issue) bool {
return is.Status == StatusOpen || is.Status == StatusDiagnosing || is.Status == StatusLocated || is.Status == StatusFixing
}
+3 -3
View File
@@ -21,9 +21,9 @@ const (
IssueCounter = "issue"
)
// The six statuses of an issue, as the development cycle names them (hq playbook 03, ADR 0275, ADR 0283).
// The six statuses of an issue, as the development cycle names them (hq playbook 03, ADR 0275, ADR 0284).
// Fixing is the status an agent or person moves an issue to when they start its fix; resolved is reached
// only from it (ADR 0283 §3).
// only from it (ADR 0284 §5).
const (
StatusOpen = "open"
StatusDiagnosing = "diagnosing"
@@ -110,7 +110,7 @@ type Issue struct {
ReadyFor []uint64 `json:"ready-for,omitempty"`
// Triage is the triage's judgement as it was made, kept before it is applied.
Triage *Verdict `json:"triage,omitempty"`
// Worker is who works on it, while it is diagnosing or fixing (ADR 0283); nil at every other status.
// Worker is who works on it, while it is diagnosing or fixing (ADR 0284); nil at every other status.
Worker *Worker `json:"worker,omitempty"`
// Opening is the id of the call that opened it, so a write whose answer was lost is known as its own.
Opening string `json:"opening"`
+3 -2
View File
@@ -44,7 +44,7 @@ type Settings struct {
// StuckAfter is how long a fix may be merged and not delivered before it is said stuck (ADR 0281), as a
// duration such as 2h; DefaultStuckAfter when empty.
StuckAfter string `json:"stuck-after,omitempty"`
// WorkerStaleAfter is how long an issue's worker stays current with nothing done on it (ADR 0283 §2), as a
// WorkerStaleAfter is how long an issue's worker stays current with nothing done on it (ADR 0284 §4), as a
// duration such as 24h; DefaultWorkerStaleAfter when empty.
WorkerStaleAfter string `json:"worker-stale-after,omitempty"`
// DeliveredNotResolvedAfter is how long an issue may stay in fixing once every fix it names is delivered before
@@ -626,7 +626,8 @@ func Tools(t *Tracker) []stdio.Tool {
Description: "Which issues track which conditions: for every condition key an issue links (link kind condition), " +
"or only the keys given, the issues that link it, those not closed (resolved or wontfix) first, each with its status, " +
"severity, what it needs, who works on it since when and whether they went quiet (stale), and where its " +
"fix is (fix: not-merged, merged, held, failed, unheard or delivered; empty when none is named). The " +
"fix is (fix: not-merged, merged, held, failed, unheard, delivered, or withdrawn when every pull request named was " +
"closed without merging; empty when none is named). The " +
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
"key's shape.",
Input: schema(nil, map[string]any{
@@ -203,7 +203,7 @@ func (h *harness) open(t *testing.T, title string, located ...string) uint64 {
return o.Number
}
// fixing moves an issue to fixing, as the agent that starts its fix does (ADR 0283).
// fixing moves an issue to fixing, as the agent that starts its fix does (ADR 0284).
func (h *harness) fixing(t *testing.T, n uint64) {
t.Helper()
if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14"}); err != nil {
+1 -1
View File
@@ -289,7 +289,7 @@ type View struct {
Ready bool `json:"ready"`
// Delivery is whether its fix is delivered, in words (ADR 0281).
Delivery string `json:"delivery,omitempty"`
// WorkerStale says nothing was done on it by its worker, or anyone, for worker-stale-after (ADR 0283 §2).
// WorkerStale says nothing was done on it by its worker, or anyone, for worker-stale-after (ADR 0284 §4).
WorkerStale bool `json:"worker-stale,omitempty"`
// DeliveredNotResolved and StaleClaim are list's flags, said of this issue (novox/hq issue 483).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
@@ -330,9 +330,9 @@ type MoveAsk struct {
Replay string
ReplayNone string
Reason string
// By is who works on it, who@node: what a move to diagnosing or fixing needs (ADR 0283 §1).
// By is who works on it, who@node: what a move to diagnosing or fixing needs (ADR 0284 §2).
By string
// TakeOver is why the issue is taken over from another worker (ADR 0283 §1).
// TakeOver is why the issue is taken over from another worker (ADR 0284 §3).
TakeOver string
}
@@ -422,7 +422,7 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
if reason != "" && to != StatusWontfix {
return nil, fmt.Errorf("reason is what wontfix needs; this moves to %s", to)
}
// Who works on it (ADR 0283 §1): named on a move to diagnosing or fixing, and on no other.
// Who works on it (ADR 0284 §2): named on a move to diagnosing or fixing, and on no other.
var worker, takeOver string
if worked(to) {
if strings.TrimSpace(a.By) == "" {
@@ -507,7 +507,7 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: by, TookOverFrom: work.from, Released: work.released,
Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)}
// The event's by is who works on it when the move names them, and who acted otherwise; caller is always who
// acted, as the runtime proves it (ADR 0283 §4).
// acted, as the runtime proves it (ADR 0284 §6).
body := map[string]any{"number": is.Number, "from": from, "to": to, "by": by, "caller": by, "at": t.now()}
if is.Worker != nil {
since := is.Worker.Since
@@ -553,7 +553,7 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
}
// cycleRules is what the development cycle asks of an issue moving from one status to another (playbook 03,
// to-be 53 §5, ADR 0283), said as the rule that refused it.
// to-be 53 §5, ADR 0284), said as the rule that refused it.
func cycleRules(is *Issue, from, to string) error {
switch to {
case StatusLocated:
@@ -562,7 +562,7 @@ func cycleRules(is *Issue, from, to string) error {
"(a repository, or \"mesh-catalog modules/<module>\")", is.Number)
}
case StatusResolved:
// Resolved only from fixing: whoever fixed it said so when they started (ADR 0283 §3). A resolved issue
// Resolved only from fixing: whoever fixed it said so when they started (ADR 0284 §5). A resolved issue
// may still have what it holds corrected.
if from != StatusFixing && from != StatusResolved {
return fmt.Errorf("issue %d is %s, and an issue is resolved only from fixing: move it to fixing with by, "+
@@ -740,7 +740,7 @@ type Row struct {
// Delivery is whether its fix is delivered, in words (ADR 0281); empty when fixed-by names no pull request.
Delivery string `json:"delivery,omitempty"`
// Worker is who works on it and Since since when, while it is diagnosing or fixing; Stale says nobody did
// anything on it for worker-stale-after, so it may be taken over without a reason (ADR 0283).
// anything on it for worker-stale-after, so it may be taken over without a reason (ADR 0284 §4).
Worker string `json:"worker,omitempty"`
Since *time.Time `json:"since,omitempty"`
Stale bool `json:"stale,omitempty"`
@@ -259,7 +259,7 @@ func TestAnIssueWithAFolderIsNumberedTitledAndPointsAtItsFolder(t *testing.T) {
}
}
// The cycle's rules at the write (to-be 53 §5, ADR 0283): one case per row, each refused case changing nothing.
// The cycle's rules at the write (to-be 53 §5, ADR 0284): one case per row, each refused case changing nothing.
// A case starts from open, or, with from, from that status reached first (fixing by fix@g14).
func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) {
const fx = StatusFixing
@@ -43,8 +43,11 @@ type Tracked struct {
// when fixed-by names no pull request; failed, held, merged (not delivered yet), not-merged, unheard (nothing
// heard from the delivery), or delivered (every pull request it names).
func FixState(is *Issue) string {
prs := fixPullRequests(is)
prs := liveFixes(is)
if len(prs) == 0 {
if len(withdrawnFixes(is)) > 0 {
return "withdrawn"
}
return ""
}
var failed, held, merged, open, unheard bool
@@ -1,6 +1,6 @@
package main
// Who works on an issue (novox/hq ADR 0283). A move to diagnosing or fixing names, in `by`, the issue's worker:
// Who works on an issue (novox/hq ADR 0284). A move to diagnosing or fixing names, in `by`, the issue's worker:
// the agent or person and its node, as who@node. The issue keeps its worker while it stays at either status, and
// `get` and `list` show it with since. A move to either by someone else while the worker is not stale is refused,
// naming the worker; a take-over gives a reason, and the reason is noted. A worker with no move, note, link or set
@@ -95,7 +95,7 @@ type workOutcome struct {
released string // the worker whose work ended, on a move to a status that has none
}
// workerFor applies a move's worker to an issue, under ADR 0283 §1 and §2: another worker not stale is refused
// workerFor applies a move's worker to an issue, under ADR 0284 §3 and §4: another worker not stale is refused
// without a take-over reason; a take-over reason with no other worker to take over from is refused.
func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter time.Duration) (workOutcome, error) {
var out workOutcome
@@ -148,5 +148,5 @@ func takeOverNote(o workOutcome, by, reason string, prev *Worker) string {
if reason != "" {
s += ". Why: " + reason
}
return s + ". (novox/hq ADR 0283)"
return s + ". (novox/hq ADR 0284)"
}
@@ -15,7 +15,7 @@ func clocked(h *harness) *time.Time {
}
// A move to diagnosing or fixing by someone else while the worker is current is refused, naming the worker, and
// changes nothing (ADR 0283 §1).
// changes nothing (ADR 0284 §3).
func TestAnotherWorkerIsRefusedNamingTheWorker(t *testing.T) {
for _, to := range []string{StatusDiagnosing, StatusFixing} {
t.Run(to, func(t *testing.T) {
@@ -76,7 +76,7 @@ func TestTheWorkerMovesOn(t *testing.T) {
}
}
// A take-over gives a reason, the reason is noted, and the moved event says from whom (ADR 0283 §1, §4).
// A take-over gives a reason, the reason is noted, and the moved event says from whom (ADR 0284 §3, §6).
func TestATakeOverNeedsAReasonAndIsNoted(t *testing.T) {
h := newHarness(t)
clocked(h)
@@ -103,7 +103,7 @@ func TestATakeOverNeedsAReasonAndIsNoted(t *testing.T) {
}
// A worker with nothing done on it past worker-stale-after is stale: list and get say so, and it may be taken over
// without a reason, noted (ADR 0283 §2). A caller's note keeps it live; the tracker's own does not.
// without a reason, noted (ADR 0284 §4). A caller's note keeps it live; the tracker's own does not.
func TestAStaleWorkerIsShownAndMayBeTakenOverWithoutAReason(t *testing.T) {
h := newHarness(t)
now := clocked(h)
@@ -193,7 +193,7 @@ func TestAMoveOnEndsTheWork(t *testing.T) {
}
}
// The duplicate check and the triage treat fixing as not closed, like diagnosing and located (ADR 0283 §5).
// The duplicate check and the triage treat fixing as not closed, like diagnosing and located (ADR 0284 §7).
func TestFixingIsNotClosed(t *testing.T) {
for _, s := range Statuses {
want := s != StatusResolved && s != StatusWontfix
@@ -261,7 +261,7 @@ func TestAResolvedIssueMayBeCorrected(t *testing.T) {
}
}
// A caller's link and set keep the worker current, as a note does (ADR 0283 §4).
// A caller's link and set keep the worker current, as a note does (ADR 0284 §4).
func TestALinkAndASetKeepTheWorkerCurrent(t *testing.T) {
for _, act := range []string{"link", "unlink", "set"} {
t.Run(act, func(t *testing.T) {