mesh-knowledge: keep when an owed review is due, say it in the history, and tell the operator once after its last try (hq issue 497)
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 superseded: a newer head of the same pull request

A failed review's try count was already kept with the version, but not when it was asked again, so a 10-minute
pause read as a review lost to a restart; and after the last try only the history and the health said so.
This commit is contained in:
2026-10-11 19:24:07 +02:00
parent fccad8c0a6
commit 9cfb5f9edb
6 changed files with 178 additions and 27 deletions
@@ -770,11 +770,6 @@ func reviewSays(r *Record) string {
case JudgingUnproven:
return "not reviewed: " + j.Reason
}
s := fmt.Sprintf("not reviewed: %s (try %d of %d); it stays proposed, and no judgement that could not be had counts as an approval", j.Reason, j.Tries, MostReviewTries)
if j.Tries >= MostReviewTries {
s += "; its author proposes it again to have it reviewed again"
} else {
s += "; asked again after a pause"
}
return s
// When it is asked again is said, so a pause is not taken for a review lost (novox/hq issue 497).
return fmt.Sprintf("not reviewed: %s (%s; no judgement that could not be had counts as an approval", j.Reason, j.owedSays())
}
@@ -141,13 +141,8 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
}
unasked := func(why string) (string, error) {
w.Ask = nil
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"
}
w.Judging = failedJudging(tries+1, why, k.now())
said := fmt.Sprintf("approved by the knowledge base's review, and %s (%s", why, w.Judging.owedSays())
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)
@@ -9,8 +9,11 @@ package main
// - On changes the record stays proposed, the findings kept with the version they judged, and its author revises
// it with a new version, which is reviewed again.
// - A judgement that cannot be had — the judge unreachable, or its answer unreadable — leaves the record proposed
// and says so on it. It is never taken as an approval. It is asked again after a pause, up to MostReviewTries;
// after that the record says its author proposes it again to have it reviewed again.
// and says so on it. It is never taken as an approval. It is owed: the try count and when the next try is due
// are kept with the version in the store, so the background work takes it up at that time, also after a restart
// of the tool runner (novox/hq issue 497), up to MostReviewTries. After the last try the record says so, its
// author proposes it again to have it reviewed again, the module's health names it, and the operator is told
// once on the operator's channel.
// - A record with a version whose writer the runtime did not prove is not reviewed: who wrote it cannot be told,
// and no one but its proven author revises it, so its author proposes it again under a new number.
// - On approve, a record that does not need the operator is accepted at once; one its author marked
@@ -35,6 +38,41 @@ type Judging struct {
Tries int `json:"tries,omitempty"`
Reason string `json:"reason,omitempty"`
At time.Time `json:"at,omitempty"`
// Due is when a failed review is asked again; zero after its last try. It is kept in the store with the
// version, so a restart of the tool runner neither loses the try nor asks it early (novox/hq issue 497).
Due time.Time `json:"due,omitempty"`
}
// ReviewPause is the pause after a failed try of a review, times the tries so far.
const ReviewPause = 10 * time.Minute
// failedJudging is a review that could not be had, the tries'th time: owed again at a time it keeps, unless that
// was its last try.
func failedJudging(tries int, reason string, now time.Time) *Judging {
j := &Judging{State: JudgingFailed, Tries: tries, Reason: reason, At: now}
if tries < MostReviewTries {
j.Due = now.Add(time.Duration(tries) * ReviewPause)
}
return j
}
// due is when a failed review is asked again. A version kept before the time was kept (novox/hq issue 497) has
// none, and is due after the same pause from its last try.
func (j *Judging) due() time.Time {
if !j.Due.IsZero() {
return j.Due
}
return j.At.Add(time.Duration(j.Tries) * ReviewPause)
}
// owedSays is what a failed try says of what follows it: when it is asked again, or that it was the last.
func (j *Judging) owedSays() string {
if j.Tries >= MostReviewTries {
return fmt.Sprintf("try %d of %d, the last); it stays proposed and nothing of it is in force, the operator is told, "+
"and its author proposes it again to have it reviewed again", j.Tries, MostReviewTries)
}
return fmt.Sprintf("try %d of %d); it stays proposed, and it is asked again at %s", j.Tries, MostReviewTries,
j.due().UTC().Format("2006-01-02 15:04 UTC"))
}
// The states of Judging.
@@ -162,7 +200,7 @@ func (k *KB) reviewDue(e *Entry) bool {
case JudgingDue:
return true
case JudgingFailed:
return w.Judging.Tries < MostReviewTries && k.now().Sub(w.Judging.At) >= time.Duration(w.Judging.Tries)*10*time.Minute
return w.Judging.Tries < MostReviewTries && !k.now().Before(w.Judging.due())
}
return false
}
@@ -218,6 +256,10 @@ func (k *KB) ReviewStep() (string, error) {
return "", err
}
for _, e := range all {
if ranOutOfTries(e.Working()) {
k.tellUnreviewed(e)
continue
}
if !k.reviewDue(e) || k.heldBack(e) {
continue
}
@@ -240,13 +282,44 @@ 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 {
if ranOutOfTries(w) {
out = append(out, fmt.Sprintf("%s (%s)", w.Key(), w.Judging.Reason))
}
}
return out
}
func ranOutOfTries(w *Record) bool {
return w.Status == StatusProposed && w.Judging != nil && w.Judging.State == JudgingFailed && w.Judging.Tries >= MostReviewTries
}
// tellUnreviewed tells the operator, once for each version, that a proposal ran out of review tries: a history line
// alone is read by nobody, and a proposal never reviewed must not wait in silence (novox/hq issue 497). The store
// keeps that it was told, so a restart does not tell it again. A tell that fails is logged; the module's health
// still names the record, so the mesh's condition stands.
func (k *KB) tellUnreviewed(e *Entry) {
if k.Notify == nil {
return
}
w := e.Working()
version := e.CurrentVersion
if e.Pending != nil {
version = e.PendingVersion
}
first, err := k.Store.Told(context.Background(), fmt.Sprintf("unreviewed %s version %d", w.Key(), version))
if err != nil || !first {
return
}
summary := fmt.Sprintf("%s was never reviewed: its review could not be had %d times (last: %s). It stays proposed, and nothing of it is in force.",
w.Key(), w.Judging.Tries, w.Judging.Reason)
explanation := "No review that could not be had counts as an approval. Its author proposes it again to have it reviewed again; " +
"until then the knowledge base's health names it. The knowledge base's get and history show it."
key := fmt.Sprintf("knowledge.unreviewed.%s-%d", w.Kind, w.Number)
if err := k.Notify(key, summary, "A proposal was never reviewed: "+w.Key().String(), explanation); err != nil {
k.logf("[mesh-knowledge] the operator could not be told that %s was never reviewed: %v; the module's health still names it", w.Key(), err)
}
}
// 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) {
@@ -312,12 +385,8 @@ func (k *KB) reviewOne(key Key) (string, error) {
}
if err != nil {
// No verdict: the record stays proposed and says so. Never an approval.
w.Judging.State, w.Judging.Reason, w.Judging.At = JudgingFailed, err.Error(), k.now()
w.Judging.Tries++
why := fmt.Sprintf("not reviewed: %v (try %d of %d); it stays proposed", err, w.Judging.Tries, MostReviewTries)
if w.Judging.Tries >= MostReviewTries {
why += ", and its author proposes it again to have it reviewed again"
}
w.Judging = failedJudging(w.Judging.Tries+1, err.Error(), k.now())
why := fmt.Sprintf("not reviewed: %v (%s", err, w.Judging.owedSays())
by = ReviewBy
if serr := save(w, why); serr != nil {
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", key, why, serr)
@@ -0,0 +1,88 @@
package main
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
// novox/hq issue 497: a review that could not be had is owed. What is owed is kept in the knowledge base's store,
// with its try count and when its next try is due, so the background work takes it up after a restart of the tool
// runner as it would have without one; the history says when it is asked again, so nobody takes a pause for a
// loss; and after its last try the history says so and the operator is told once. It is never an approval.
func TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
n := proposed(t, w, "author@g14", false)
key := Key{KindDecision, n}
lastWhy := func() string {
vs, err := s.Versions(context.Background(), key)
if err != nil || len(vs) == 0 {
t.Fatalf("versions of %s: %v", key, err)
}
return vs[len(vs)-1].Why
}
var due time.Time
for try := 1; try <= MostReviewTries; try++ {
if try > 1 {
// The tool runner restarts: a new knowledge base over the same store, nothing of the old one's memory.
w = newWorld(t, s, fixture())
w.clock = due.Add(-time.Second)
if said := w.reviewAll(t); len(said) != 0 {
t.Fatalf("try %d asked before it was due: %v", try, said)
}
w.clock = due
}
w.judge.reviewFail = true
if said := w.reviewAll(t); len(said) != 1 || !strings.Contains(said[0], "not reviewed") {
t.Fatalf("try %d after a restart was not taken up: %v", try, said)
}
e := mustGet(t, s, key)
if e.Current.Status != StatusProposed || e.Current.Judging == nil || e.Current.Judging.State != JudgingFailed ||
e.Current.Judging.Tries != try || len(e.Current.Reviews) != 0 {
t.Fatalf("try %d is not counted in the store: %s, %+v", try, e.Current.Status, e.Current.Judging)
}
why := lastWhy()
if !strings.Contains(why, fmt.Sprintf("try %d of %d", try, MostReviewTries)) {
t.Fatalf("try %d: the history does not count it: %q", try, why)
}
if try < MostReviewTries {
due = w.clock.Add(time.Duration(try) * 10 * time.Minute)
when := "asked again at " + due.Format("2006-01-02 15:04 UTC")
if !strings.Contains(why, when) {
t.Fatalf("try %d: the history does not say when it is asked again (%q): %q", try, when, why)
}
if g, _ := w.k.Get(GetAsk{Number: n}); !strings.Contains(g.Review, when) {
t.Fatalf("try %d: get does not say when it is asked again (%q): %q", try, when, g.Review)
}
if len(w.told) != 0 {
t.Fatalf("try %d: the operator was told before the last try: %v", try, w.told)
}
}
}
// After its last try: the history says it was the last, and the operator is told, once.
if why := lastWhy(); !strings.Contains(why, "the last") || !strings.Contains(why, "the operator is told") {
t.Fatalf("the last try is not said in the history: %q", why)
}
if len(w.told) != 1 || !strings.Contains(w.told[0], key.String()) || !strings.Contains(w.told[0], "never reviewed") {
t.Fatalf("the operator is not told once of the last try: %v", w.told)
}
w.clock = w.clock.Add(24 * time.Hour)
if said := w.reviewAll(t); len(said) != 0 || len(w.told) != 1 {
t.Fatalf("after the last try: asked %v, told %v", said, w.told)
}
// Another restart neither asks it again nor tells the operator again.
clock := w.clock
w = newWorld(t, s, fixture())
w.clock = clock
if said := w.reviewAll(t); len(said) != 0 || len(w.told) != 0 {
t.Fatalf("after a restart past the last try: asked %v, told %v", said, w.told)
}
// Never an approval.
if e := mustGet(t, s, key); e.Current.Status != StatusProposed || len(w.said.of(EventAccepted)) != 0 {
t.Fatalf("a review never had approved it: %s", e.Current.Status)
}
})
}
@@ -63,7 +63,8 @@ type Store interface {
// SetFindings replaces the whole-set findings; Findings reads them.
SetFindings(ctx context.Context, f []Finding) error
Findings(ctx context.Context) ([]Finding, error)
// Told records that the operator was told about a topic's clauses, once; false when it was already.
// Told records that the operator was told of something, once, by its key: a topic's clauses drafted, or a
// version that ran out of review tries (novox/hq issue 497); false when it was already.
Told(ctx context.Context, topic string) (bool, error)
}