Files
mesh-catalog/modules/messenger/cmd/messenger/tracking.go
T
jochen a7fc89ef04
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → 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)
Name issues not closed first, and say a quiet worker before a decision, as ADR 0285 says
The router's order put a wontfix issue before an open one, and a stale
worker on an issue waiting for a decision said both; review of #191.
2026-10-10 21:50:54 +02:00

346 lines
12 KiB
Go

package main
// Which issue tracks a condition, said in its message to the operator (novox/hq issue 400, ADR 0285). An issue names the
// conditions it tracks by their keys, through the issue tracker's link kind `condition`; the tracker's verb
// `tracking` answers, in one call, every condition key an issue links and where each of those issues stands. The
// router asks it outside the work, keeps the answer, and every message about a condition says one line from it:
//
// - "Tracked as issue 386: nobody is working on it." — or who is, since when; or that its fix is merged and on
// its way, delivered and waiting to be checked, or resolved while this still happens; or that it waits for the
// operator's answer or for a decision.
// - "No issue tracks this yet." when no issue links the condition's key.
// - "Whether an issue tracks this could not be checked." when the tracker gave no answer within TrackingKept:
// never a guess, and never silence that reads as "untracked".
//
// The line changes the verdict in two cases, and never when a message is sent (ADR 0253's rules stand):
//
// - "Nothing for you to do." becomes "Needs you: answer issue N." when an issue tracking it waits for the
// operator's answer: the operator is never told there is nothing to do while the mesh waits on them.
// - "Nothing for you to do." is left out when every issue tracking it is one nobody is working on, so the
// message does not reassure where nothing is being done; the line then says so.
//
// The issue number is the one numbered id a message may carry (ADR 0285): the operator names an issue by it.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// TrackingVerb is the issue tracker's verb the router asks which issues track which conditions.
const TrackingVerb = "seat:issue-tracker.tracking"
const (
// TrackingWithin is how long the router waits for the tracker's answer.
TrackingWithin = 5 * time.Second
// TrackingAskEvery is how often the router asks again; a failed ask is tried again after it too.
TrackingAskEvery = time.Minute
// TrackingKept is how long an answer is used: older, a message says the tracking could not be checked.
TrackingKept = 10 * time.Minute
// TrackingNamed is the most issues one message names; the rest are counted.
TrackingNamed = 3
)
// TrackedIssue is one issue that tracks a condition, as the tracker answers it.
type TrackedIssue struct {
Number uint64 `json:"number"`
Status string `json:"status"`
Needs string `json:"needs,omitempty"`
// Worker is who works on it, as who@node, while it is diagnosing or fixing; Active the last time they did
// anything on it; Stale that it is longer ago than the tracker's bound.
Worker string `json:"worker,omitempty"`
Since *time.Time `json:"since,omitempty"`
Active *time.Time `json:"active,omitempty"`
Stale bool `json:"stale,omitempty"`
// Fix is where its fix stands, as the delivery last said it: empty when no fix is named; not-merged,
// merged, held, failed, unheard or delivered.
Fix string `json:"fix,omitempty"`
}
// trackWord is the tracker's last answer, and when it was asked.
type trackWord struct {
at time.Time // when it was answered; zero when it never was
tried time.Time // when it was last asked, answered or not
conditions map[string][]TrackedIssue
err string
}
// askTracking asks the tracker which issues track which conditions, failing on no answer in time.
func (h *Holder) askTracking() (map[string][]TrackedIssue, error) {
if h.Tracking != nil {
return h.Tracking()
}
if h.Ask == nil {
return nil, errors.New("this router cannot ask the issue tracker")
}
type answer struct {
raw json.RawMessage
err error
}
done := make(chan answer, 1)
go func() {
raw, err := h.Ask(TrackingVerb, map[string]any{})
done <- answer{raw, err}
}()
var a answer
select {
case a = <-done:
case <-time.After(TrackingWithin):
return nil, fmt.Errorf("the issue tracker did not answer within %s", TrackingWithin)
}
if a.err != nil {
return nil, a.err
}
var body struct {
Conditions map[string][]TrackedIssue `json:"conditions"`
}
if err := json.Unmarshal(a.raw, &body); err != nil {
return nil, fmt.Errorf("the issue tracker's answer could not be read: %v", err)
}
if body.Conditions == nil {
body.Conditions = map[string][]TrackedIssue{}
}
return body.Conditions, nil
}
// warmTracking asks the tracker again, outside the work, when its last answer is TrackingAskEvery old; what is
// composed inside the work reads the answer kept.
func (h *Holder) warmTracking() {
if h.Tracking == nil && h.Ask == nil {
return
}
now := h.Now()
h.mu.Lock()
tried := h.track.tried
if !tried.IsZero() && now.Sub(tried) >= 0 && now.Sub(tried) < TrackingAskEvery {
h.mu.Unlock()
return
}
h.track.tried = now // marked before asking, so two callers at once ask once
h.mu.Unlock()
got, err := h.askTracking()
h.mu.Lock()
defer h.mu.Unlock()
h.track.tried = now
if err != nil {
if h.track.err != err.Error() && h.Logf != nil {
h.Logf("[messenger] could not ask which issues track the conditions: %v", err)
}
h.track.err = err.Error()
return
}
h.track = trackWord{at: now, tried: now, conditions: got}
}
// trackedBy is the issues that track a condition key, and whether the tracker's word is recent enough to say so.
// ok false: no recent word, and the message says it could not be checked.
func (h *Holder) trackedBy(key string) ([]TrackedIssue, bool) {
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
if h.track.at.IsZero() || now.Sub(h.track.at) > TrackingKept || now.Sub(h.track.at) < -ClockSlack {
return nil, false
}
return h.track.conditions[key], true
}
// tracksAsked says whether this router asks the tracker at all; one that cannot (a test's) says nothing of it.
func (h *Holder) tracksAsked() bool { return h.Tracking != nil || h.Ask != nil }
// The verdicts a condition's explanation opens with (novox/hq ADR 0253).
const (
verdictNothing = "Nothing for you to do."
verdictNeeds = "Needs you:"
)
// withTracking is a condition's message with the line on the issues that track it, and its verdict changed where
// an issue waits for the operator or nobody works on any of them. Cleared and silenced messages, and one withheld
// whole, say nothing of it.
func withTracking(m Message, r *Record, what string, issues []TrackedIssue, known bool, now time.Time, loc *time.Location) Message {
if r.Origin != "condition" || what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
return m
}
line, verdict := trackingWords(issues, known, now, loc)
// since is the last line, which never holds a line break; the explanation is everything before it.
explanation, since := "", m.Body
if i := strings.LastIndex(m.Body, "\n"); i >= 0 {
explanation, since = m.Body[:i], m.Body[i+1:]
}
if rest, ok := strings.CutPrefix(explanation, verdictNothing); ok {
switch verdict {
case "needs":
explanation = verdictNeeds + " " + needsAnswer(issues) + rest
case "nobody":
explanation = strings.TrimSpace(rest)
}
}
parts := []string{}
for _, p := range []string{explanation, line, since} {
if p != "" {
parts = append(parts, p)
}
}
m.Body = strings.Join(parts, "\n")
return m
}
// needsAnswer is the "Needs you:" sentence for the issues that wait for the operator's answer.
func needsAnswer(issues []TrackedIssue) string {
var nums []uint64
for _, is := range issues {
if waitsForOperator(is) {
nums = append(nums, is.Number)
}
}
sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] })
var ns []string
for _, n := range nums {
ns = append(ns, fmt.Sprint(n))
}
if len(ns) == 1 {
return "answer issue " + ns[0] + "."
}
return "answer issues " + joinAnd(ns) + "."
}
// trackingWords is the line said of the issues that track a condition, and what it does to a "Nothing for you to
// do." verdict: "needs" (an issue waits for the operator), "nobody" (nobody works on any of them) or "".
func trackingWords(issues []TrackedIssue, known bool, now time.Time, loc *time.Location) (string, string) {
if !known {
return "Whether an issue tracks this could not be checked.", ""
}
if len(issues) == 0 {
return "No issue tracks this yet.", ""
}
sorted := append([]TrackedIssue(nil), issues...)
sort.SliceStable(sorted, func(i, j int) bool {
ri, rj := closed(sorted[i]), closed(sorted[j])
if ri != rj {
return !ri
}
return sorted[i].Number < sorted[j].Number
})
var sentences []string
for i, is := range sorted {
if i == TrackingNamed {
more := len(sorted) - TrackingNamed
if more == 1 {
sentences = append(sentences, "And 1 more issue.")
} else {
sentences = append(sentences, fmt.Sprintf("And %d more issues.", more))
}
break
}
lead := "Tracked as issue "
if i > 0 {
lead = "Also as issue "
}
sentences = append(sentences, lead+fmt.Sprint(is.Number)+": "+issueState(is, now, loc)+".")
}
verdict := "nobody"
for _, is := range sorted {
if waitsForOperator(is) {
verdict = "needs"
break
}
if !nobodyOn(is, now) {
verdict = ""
}
}
return strings.Join(sentences, " "), verdict
}
// waitsForOperator says whether an issue waits for the operator's answer: it needs the operator, and no fix of it
// is merged yet.
func waitsForOperator(is TrackedIssue) bool {
return is.Needs == "operator" && !closed(is) && !fixUnderway(is)
}
// closed: resolved or wontfix.
func closed(is TrackedIssue) bool { return is.Status == "resolved" || is.Status == "wontfix" }
// fixUnderway: its fix is merged, whatever the delivery did with it since.
func fixUnderway(is TrackedIssue) bool {
switch is.Fix {
case "merged", "held", "failed", "delivered":
return true
}
return false
}
// working: someone works on it now.
func working(is TrackedIssue) bool { return is.Worker != "" && !is.Stale }
// nobodyOn says whether nothing is being done on an issue: nobody works on it, and it is closed while this still
// happens, or no fix of it is written or merged.
func nobodyOn(is TrackedIssue, now time.Time) bool {
if working(is) {
return false
}
return closed(is) || (!fixUnderway(is) && is.Fix != "not-merged" && is.Fix != "unheard")
}
// issueState is where one issue stands, in plain words, after "Tracked as issue N: ".
func issueState(is TrackedIssue, now time.Time, loc *time.Location) string {
switch {
case is.Status == "resolved":
return "it was resolved, yet this still happens"
case is.Status == "wontfix":
return "it will not be fixed"
case is.Fix == "delivered":
return "its fix is delivered and waits to be checked"
case is.Fix == "merged":
return "its fix is merged and on its way"
case is.Fix == "held":
return "its fix is merged and held before it is delivered"
case is.Fix == "failed":
return "its fix is merged, but its delivery failed"
case working(is):
doing := "is fixing it"
if is.Status == "diagnosing" {
doing = "is looking for its cause"
}
s := workerWords(is.Worker) + " " + doing
if is.Since != nil {
s += " since " + when(*is.Since, now, loc)
}
return s
case is.Fix == "not-merged":
return "a fix is written and waits to be merged"
case is.Fix == "unheard":
return "a fix is named, and nothing is heard yet of its delivery"
case waitsForOperator(is):
return "it waits for your answer"
}
if is.Worker != "" && is.Active != nil {
return "nobody has worked on it since " + when(*is.Active, now, loc)
}
if is.Needs == "decision" {
return "nobody is working on it; it waits for a decision first"
}
return "nobody is working on it"
}
// workerWords is who@node as said: "fix-400 on g14".
func workerWords(worker string) string {
who, node, ok := strings.Cut(worker, "@")
if !ok {
return worker
}
return who + " on " + node
}
// joinAnd is "a", "a and b", "a, b and c".
func joinAnd(xs []string) string {
switch len(xs) {
case 0:
return ""
case 1:
return xs[0]
}
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
}