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
+5 -2
View File
@@ -104,8 +104,11 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
Its author revises it by proposing it again, and that version is reviewed again.
- 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, and the module is unhealthy, naming it, so the mesh raises a condition.
approval. It is owed: its try count and when it is asked again (10, then 20 minutes later) are kept with the
version in the store and said in its history, so the background work takes it up at that time, also after a
restart of the tool runner (hq issue 497). After 3 tries the record says it was the last and its author
proposes it again to have it reviewed again; the operator is told once on the operator's channel, 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.
@@ -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)
}