mesh/merge-gate pass: builds 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)
mesh/delivery superseded: a newer head of the same pull request
The review of #192 found three faults, all verified by running. The tests did not prove the claim the change leads with: a probe that sent, queued, recorded and wrote from inside Preview still passed, because the condition they used was a warning needing nothing, which is held rather than sent. They now hold an urgent condition, snapshot what a send would move — the channels, the queue, the history, the refusals, the stored record and the record held — assert none of it moved, and tick once more. Every one of those probes now fails the tests. `cleared` and `silenced` said wording no send would carry: an open record has no clearing time, so a condition open four hours cleared "after a moment", and no silence, so a silence read "until Mon 1 Jan 00:17". A clearing is now composed as if it cleared now, which the answer says; a silence is refused unless the condition is under one. The preview composed only its own words, while a send reduces a message whose words the content rule refuses. The escalation walk is now one function, allowedWords, which `words` calls for a send and the preview calls alone — so the words read are the words that would leave, and reading them records no refusal and states nothing on the bus. withTracking hands back the line it put in, so the preview reports that line instead of deciding the rule a second time; and a record that is not a condition, or one carrying an open ask, is refused or says that a channel's message carries more.
348 lines
12 KiB
Go
348 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.
|
|
// It answers the line it put in the message, empty when the message carries none, so whoever composed it
|
|
// never has to work out the rule a second time (novox/hq issue 422).
|
|
func withTracking(m Message, r *Record, what string, issues []TrackedIssue, known bool, now time.Time, loc *time.Location) (Message, string) {
|
|
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, line
|
|
}
|
|
|
|
// 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]
|
|
}
|