mesh-knowledge: bound every review failure per record, make one out of tries loud, and let only its author revise a pending insight (review of #211)
mesh/merge-gate pass: builds mesh-knowledge → 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 covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…

This commit is contained in:
2026-10-11 12:41:24 +02:00
parent ebba63d71f
commit 6fb827605a
6 changed files with 242 additions and 25 deletions
+5 -1
View File
@@ -92,7 +92,11 @@ record it goes out from.
- A judgement that cannot be had (the judge unreachable, or an answer that is not `approve`, or `changes` naming
no finding) leaves the record proposed and says so on it (`get`'s `review`). It is never taken as an
approval. It is asked again after 10, then 20 minutes; after 3 tries the record says its author proposes it
again to have it reviewed again.
again to have it reviewed again, and the module is unhealthy, naming it, so the mesh raises a condition.
An ask of the operator that cannot be made counts as a try the same way.
- A review whose outcome the database refused to keep is held back for that record alone (10, then 20
minutes, 3 tries, logged as given up), so it never blocks the records behind it.
- A progressive insight waiting for its review is revised only by its proven author.
- A record revised while it is judged is not decided on the version judged; the newest is judged next.
- On approve the record and its clauses are accepted and in force. Every clause it changes moves to
`changed`, and every record it supersedes moves to `superseded`, each as a new version of that record. A record marked `needs-operator` is first asked of the
@@ -83,6 +83,9 @@ type KB struct {
// mu serialises the writes: one instance holds the seat, and a write reads, judges and writes as one.
mu sync.Mutex
stuckMu sync.Mutex
stuck map[Key]*stuckReview
// kick wakes the background work when a proposal waits for its review; nil where nothing runs in the
// background (the tests take each step themselves).
kick chan struct{}
@@ -321,6 +324,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
why = "revised"
}
case held.Current.Accepted():
if held.Pending != nil && (!proven(caller) || caller != held.Pending.Author) {
return nil, fmt.Errorf("%s has a progressive insight by %s waiting for its review: only its author, proven by the "+
"runtime, revises it (this call is by %s). Nothing was proposed", key, held.Pending.Author, caller)
}
if strings.TrimSpace(a.Insight) == "" {
return nil, fmt.Errorf("%s is %s: it changes only by a new record, or by a progressive insight, a correction of fact "+
"that leaves the decision standing (give insight: what stood before). Nothing was proposed", key, held.Current.Status)
@@ -474,8 +474,9 @@ type HealthAnswer struct {
At time.Time `json:"at,omitempty"`
}
// Health is how the knowledge base is: unhealthy when its database cannot serve, or when a rule that judges the
// whole record set fails — the mesh raises a condition naming the rule and the records.
// Health is how the knowledge base is: unhealthy when its database cannot serve, when a rule that judges the
// whole record set fails, or when a proposed record ran out of review tries — the mesh raises a condition naming
// the rule, the records, or the records not reviewed.
func (k *KB) Health(ready Healther) HealthAnswer {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
@@ -488,25 +489,42 @@ func (k *KB) Health(ready Healther) HealthAnswer {
if err != nil {
return HealthAnswer{State: "unhealthy", Why: "the whole-set findings cannot be read: " + err.Error()}
}
if len(findings) > 0 {
var says []string
for _, f := range findings {
says = append(says, "the rule "+f.Rule+": "+f.Says)
}
sort.Strings(says)
if len(says) > 8 {
says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8))
}
return HealthAnswer{State: "unhealthy", Why: fmt.Sprintf("%d rule(s) that judge the whole record set fail: %s", len(findings), strings.Join(says, "; "))}
var says []string
for _, f := range findings {
says = append(says, "the rule "+f.Rule+": "+f.Says)
}
return HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves, and every rule that judges the whole record set holds"}
sort.Strings(says)
all, err := k.Store.All(ctx)
if err != nil {
return HealthAnswer{State: "unhealthy", Why: "the records cannot be read: " + err.Error()}
}
unreviewed := Unreviewed(all)
if len(findings)+len(unreviewed) > 0 {
var parts []string
if len(unreviewed) > 0 {
if len(unreviewed) > 8 {
unreviewed = append(unreviewed[:8], fmt.Sprintf("and %d more", len(unreviewed)-8))
}
parts = append(parts, fmt.Sprintf("%d proposed record(s) could not be reviewed in %d tries and wait for their authors to propose them again: %s",
len(Unreviewed(all)), MostReviewTries, strings.Join(unreviewed, "; ")))
}
if len(says) > 0 {
if len(says) > 8 {
says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8))
}
parts = append(parts, fmt.Sprintf("%d rule(s) that judge the whole record set fail: %s", len(findings), strings.Join(says, "; ")))
}
return HealthAnswer{State: "unhealthy", Why: strings.Join(parts, ". ")}
}
return HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves, every rule that judges the whole record set holds, and no proposal ran out of review tries"}
}
func healthTool(status string, now func() time.Time) stdio.Tool {
return stdio.Tool{Name: HealthTool,
Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version and every " +
"rule that judges the whole record set holds; unhealthy otherwise, naming the rule and the records — a decision accepted " +
"that nothing cites, a supersession or a change said on one end only, a record extending a superseded one.",
"that nothing cites, a supersession or a change said on one end only, a record extending a superseded one — or when a " +
"proposed record ran out of review tries, naming it.",
Run: func(map[string]any) (any, error) { return ReadStatus(status, now()), nil }}
}
@@ -131,17 +131,23 @@ func newAskID(r *Record) string {
}
// askOperator asks the operator to approve a record the knowledge base's review approved, and keeps the ask with
// it. The caller holds the write lock. An ask that could not be published is taken back, and the review is asked
// again after a pause: the record stays proposed.
func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string) (string, error) {
// it. The caller holds the write lock. An ask that could not be made or published is taken back, and the review is
// asked again after a pause, counted with the tries before it (tries): the record stays proposed, and is reviewed
// at most MostReviewTries times.
func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string, tries int) (string, error) {
version := held.CurrentVersion
if held.Pending != nil {
version = held.PendingVersion
}
unasked := func(why string) (string, error) {
w.Ask = nil
w.Judging = &Judging{State: JudgingFailed, Tries: 1, Reason: why, At: k.now()}
said := "approved by the knowledge base's review, and " + why + "; it stays proposed and is reviewed again"
w.Judging = &Judging{State: JudgingFailed, Tries: tries + 1, Reason: why, At: k.now()}
said := fmt.Sprintf("approved by the knowledge base's review, and %s (try %d of %d); it stays proposed", why, tries+1, MostReviewTries)
if tries+1 >= MostReviewTries {
said += ", and its author proposes it again to have it reviewed again"
} else {
said += " and is reviewed again after a pause"
}
e, err := k.Store.Get(ctx, w.Key())
if err != nil || e == nil {
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err)
@@ -156,8 +156,48 @@ func (k *KB) reviewDue(e *Entry) bool {
return false
}
// ReviewStep reviews the first proposed version waiting for its review. It answers what it did, or "" when
// nothing waits.
// stuckReview is a record whose review could not be kept — the store refused the write, or the acceptance
// failed — so its state never reached the store: held here, per record and per revision, so it is tried again
// only after a pause, at most MostReviewTries times, and never blocks the records behind it.
type stuckReview struct {
revision int64
tries int
at time.Time
}
// held says whether a record's review waits here, after a failure the store could not keep.
func (k *KB) heldBack(e *Entry) bool {
k.stuckMu.Lock()
defer k.stuckMu.Unlock()
s := k.stuck[e.Current.Key()]
if s == nil || s.revision != e.Revision {
return false
}
return s.tries >= MostReviewTries || k.now().Sub(s.at) < time.Duration(s.tries)*10*time.Minute
}
func (k *KB) noteStuck(e *Entry, err error) {
k.stuckMu.Lock()
defer k.stuckMu.Unlock()
if k.stuck == nil {
k.stuck = map[Key]*stuckReview{}
}
key := e.Current.Key()
s := k.stuck[key]
if s == nil || s.revision != e.Revision {
s = &stuckReview{revision: e.Revision}
k.stuck[key] = s
}
s.tries++
s.at = k.now()
if s.tries >= MostReviewTries {
k.logf("[mesh-knowledge] REVIEW GIVEN UP: the review of %s could not be kept %d times (last: %v); it stays proposed", key, s.tries, err)
}
}
// ReviewStep reviews the first proposed version waiting for its review and not held back. It answers what it
// did, or "" when nothing waits; an error is one record's, which is then held back, so the next step takes the
// next record.
func (k *KB) ReviewStep() (string, error) {
if k.Judge == nil {
return "", nil
@@ -167,13 +207,35 @@ func (k *KB) ReviewStep() (string, error) {
return "", err
}
for _, e := range all {
if k.reviewDue(e) {
return k.reviewOne(e.Current.Key())
if !k.reviewDue(e) || k.heldBack(e) {
continue
}
said, err := k.reviewOne(e.Current.Key())
if err != nil {
k.noteStuck(e, err)
return "", err
}
k.stuckMu.Lock()
delete(k.stuck, e.Current.Key())
k.stuckMu.Unlock()
return said, nil
}
return "", nil
}
// Unreviewed are the proposed records that ran out of review tries: no judgement could be had, or the operator
// could not be asked. They make the module unhealthy, so the mesh raises a condition naming them.
func Unreviewed(all []*Entry) []string {
var out []string
for _, e := range all {
w := e.Working()
if w.Status == StatusProposed && w.Judging != nil && w.Judging.State == JudgingFailed && w.Judging.Tries >= MostReviewTries {
out = append(out, fmt.Sprintf("%s (%s)", w.Key(), w.Judging.Reason))
}
}
return out
}
// reviewOne asks the judgement on one record's working version, and acts on its verdict. The write lock is held
// only to read and to write, never across the judgement; a record revised while it was judged is judged again.
func (k *KB) reviewOne(key Key) (string, error) {
@@ -252,6 +314,7 @@ func (k *KB) reviewOne(key Key) (string, error) {
k.emit(EventReviewed, w, by, why, now.Versions+1)
return key.String() + ": " + why, nil
}
tries := w.Judging.Tries
w.Judging = nil
w.Reviews = append(w.Reviews, Review{By: by, At: k.now(), Verdict: verdict, Findings: strings.Join(findings, "\n"), Version: version})
if verdict == VerdictChanges {
@@ -263,7 +326,7 @@ func (k *KB) reviewOne(key Key) (string, error) {
return key.String() + ": " + why, nil
}
if w.NeedsOperator {
return k.askOperator(ctx, now, w, by)
return k.askOperator(ctx, now, w, by, tries)
}
k.emit(EventReviewed, w, by, "approved", now.Versions+1)
r, err := k.accept(ctx, now, w, by, "accepted after the knowledge base's review", "")
@@ -0,0 +1,119 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// The review of mesh-catalog#211: every way a review can fail is bounded, per record, and loud.
// A needs-operator record whose ask cannot be made is reviewed again at most MostReviewTries times, however long
// the clock runs: the tries are carried, not started again at each failure.
func TestAnAskThatCannotBeMadeIsTriedABoundedNumberOfTimes(t *testing.T) {
w := imported(t, newMemStore())
w.k.Asks = nil
n := proposed(t, w, "author@g14", true)
for i := 0; i < 6*48; i++ { // two days, every ten minutes
w.reviewAll(t)
w.clock = w.clock.Add(10 * time.Minute)
}
e := mustGet(t, w.k.Store, Key{KindDecision, n})
if len(w.judge.reviews) != MostReviewTries || e.Current.Judging == nil || e.Current.Judging.Tries != MostReviewTries ||
len(e.Current.Reviews) > MostReviewTries || e.Current.Status != StatusProposed {
t.Fatalf("%d judgements, judging %+v, %d reviews kept", len(w.judge.reviews), e.Current.Judging, len(e.Current.Reviews))
}
}
// failingSaves is a store whose writes of one record fail, as a database refusing them would.
type failingSaves struct {
*memStore
key Key
fail bool
}
func (f *failingSaves) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error {
if f.fail && v.Record != nil && v.Record.Key() == f.key {
return errors.New("the database refused the write")
}
return f.memStore.Save(ctx, v, current, clearPending, revision)
}
// A record whose review cannot be kept does not block the others, and is tried a bounded number of times.
func TestARecordWhoseReviewCannotBeKeptBlocksNoOtherAndIsTriedBoundedly(t *testing.T) {
s := &failingSaves{memStore: newMemStore()}
w := imported(t, s)
a := proposed(t, w, "author@g14", false)
b := proposed(t, w, "author@g14", false)
s.key, s.fail = Key{KindDecision, a}, true
errs := 0
for i := 0; i < 6*48; i++ {
for j := 0; j < 5; j++ {
said, err := w.k.ReviewStep()
if err != nil {
errs++
continue
}
if said == "" {
break
}
}
w.clock = w.clock.Add(10 * time.Minute)
}
if got := mustGet(t, s, Key{KindDecision, b}).Current.Status; got != StatusAccepted {
t.Fatalf("ADR %04d stayed %s behind a record whose review could not be kept", b, got)
}
if errs == 0 || errs > MostReviewTries {
t.Fatalf("the failing record was tried %d times; at most %d", errs, MostReviewTries)
}
if mustGet(t, s, Key{KindDecision, a}).Current.Status != StatusProposed {
t.Fatal("accepted without its review kept")
}
}
// A record that ran out of review tries makes the module unhealthy, naming it: the mesh raises a condition.
func TestARecordThatCouldNotBeReviewedMakesTheModuleUnhealthy(t *testing.T) {
w := imported(t, newMemStore())
n := proposed(t, w, "author@g14", false)
w.judge.reviewFail = true
for i := 0; i < MostReviewTries; i++ {
w.reviewAll(t)
if h := w.k.Health(nil); i < MostReviewTries-1 && !h.Healthy {
t.Fatalf("unhealthy while tries remain: %s", h.Why)
}
w.clock = w.clock.Add(time.Hour)
}
h := w.k.Health(nil)
if h.Healthy || !strings.Contains(h.Why, itoaKey(n)) || !strings.Contains(h.Why, "could not be reviewed") {
t.Fatalf("%+v", h)
}
}
// A progressive insight waiting for its review is replaced only by its author, proven.
func TestAPendingInsightIsReplacedOnlyByItsAuthor(t *testing.T) {
w := imported(t, newMemStore())
n := w.reserved(t, "author@g14")
w.accepted(t, "author@g14", valid(n))
p := valid(n)
p.Sections[2].Text = "Merges land sooner, by about a minute."
p.Insight = "it said only that merges land sooner"
if _, err := w.k.Propose("corrector@ace", p); err != nil {
t.Fatal(err)
}
q := valid(n)
q.Sections[2].Text = "Merges land later."
q.Insight = "it said merges land sooner"
for _, other := range []string{"author@g14", "other@novox", Unproven} {
if _, err := w.k.Propose(other, q); err == nil || !strings.Contains(err.Error(), "only its author") {
t.Fatalf("a pending insight replaced by %s: %v", other, err)
}
}
if e := mustGet(t, w.k.Store, Key{KindDecision, n}); e.Pending.Section("Consequences") != p.Sections[2].Text {
t.Fatalf("the pending insight was replaced: %q", e.Pending.Section("Consequences"))
}
if _, err := w.k.Propose("corrector@ace", q); err != nil {
t.Fatalf("its author's revision: %v", err)
}
}