mesh-issues: review — one judgement at a time with new issues first, one verdict applied at a time, a resolved issue left alone on redelivery, the pass's answer a copy (hq ADR 0278)
mesh/merge-gate pass: builds claude-code, mesh-issues → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 o…
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 delivered
mesh/merge-gate pass: builds claude-code, mesh-issues → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 o…
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 delivered
An independent review found a pass could starve new issues' checks, two writers could apply one verdict twice, a judged issue resolved by a person could still be linked, and the dry run's answer raced its pass.
This commit is contained in:
@@ -63,6 +63,10 @@ older ones not resolved and not found a duplicate in the same pass. It writes on
|
|||||||
`grooming` (the last pass, one key), and applies that pass's findings on `confirm` — never a fresh judgement.
|
`grooming` (the last pass, one key), and applies that pass's findings on `confirm` — never a fresh judgement.
|
||||||
A finding whose issues have moved on is skipped and said. It adds no "none" note to the backlog.
|
A finding whose issues have moved on is skipped and said. It adds no "none" note to the backlog.
|
||||||
|
|
||||||
|
The tracker asks one judgement at a time; the pass waits while a new issue's check is waiting, and one verdict is
|
||||||
|
applied at a time. A check may take up to a minute (two judgements of at most 25 seconds each), which the tool
|
||||||
|
runner allows a handler while keeping its event alive (hq issue 387).
|
||||||
|
|
||||||
## The import of hq's issues (slice 3)
|
## The import of hq's issues (slice 3)
|
||||||
|
|
||||||
`issue-tracker.import` reads every `04-ISSUES/NNN-slug/00-report.md` of hq **through the records module
|
`issue-tracker.import` reads every `04-ISSUES/NNN-slug/00-report.md` of hq **through the records module
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
@@ -87,8 +88,10 @@ type Verdict struct {
|
|||||||
Issue uint64 `json:"issue,omitempty"`
|
Issue uint64 `json:"issue,omitempty"`
|
||||||
Certain bool `json:"certain,omitempty"`
|
Certain bool `json:"certain,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
// Checked is how many older issues not resolved it was judged against.
|
// Checked is how many older issues not resolved it was judged against; Older how many there were, more
|
||||||
|
// when their titles did not all fit the question.
|
||||||
Checked int `json:"checked"`
|
Checked int `json:"checked"`
|
||||||
|
Older int `json:"older,omitempty"`
|
||||||
// By is who judged: the agent, its node and model.
|
// By is who judged: the agent, its node and model.
|
||||||
By string `json:"by,omitempty"`
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -275,7 +278,7 @@ func (t *Tracker) judge(is *Issue, candidates []*Issue) (*Verdict, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(shortlist) == 0 {
|
if len(shortlist) == 0 {
|
||||||
return &Verdict{Verdict: VerdictNone, Checked: len(offered), By: by}, nil
|
return &Verdict{Verdict: VerdictNone, Checked: len(offered), Older: len(candidates), By: by}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -298,7 +301,7 @@ func (t *Tracker) judge(is *Issue, candidates []*Issue) (*Verdict, error) {
|
|||||||
if err := json.Unmarshal(raw, &v); err != nil {
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
return nil, fmt.Errorf("the verdict came back outside the shape asked: %.200s", raw)
|
return nil, fmt.Errorf("the verdict came back outside the shape asked: %.200s", raw)
|
||||||
}
|
}
|
||||||
v.By, v.Checked, v.Reason = by, len(offered), strings.TrimSpace(v.Reason)
|
v.By, v.Checked, v.Older, v.Reason = by, len(offered), len(candidates), strings.TrimSpace(v.Reason)
|
||||||
switch v.Verdict {
|
switch v.Verdict {
|
||||||
case VerdictNone:
|
case VerdictNone:
|
||||||
v.Issue, v.Certain = 0, false
|
v.Issue, v.Certain = 0, false
|
||||||
@@ -337,14 +340,21 @@ func verdictNote(v *Verdict) string {
|
|||||||
if v.By != "" {
|
if v.By != "" {
|
||||||
by = " Judged by " + v.By + "."
|
by = " Judged by " + v.By + "."
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%snone of %d older issues not resolved asks the same question, and none is closely related.%s "+
|
most := ""
|
||||||
"(novox/hq ADR 0278)", notePrefix, v.Checked, by)
|
if v.Older > v.Checked {
|
||||||
|
most = fmt.Sprintf(" They were the %d most recent of %d; the titles of the rest did not fit the question.", v.Checked, v.Older)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%snone of %d older issues not resolved asks the same question, and none is closely related.%s%s "+
|
||||||
|
"(novox/hq ADR 0278)", notePrefix, v.Checked, most, by)
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply does what a verdict on the newer issue says (ADR 0278 §2, §3), each step only when it is not done
|
// apply does what a verdict on the newer issue says (ADR 0278 §2, §3), each step only when it is not done
|
||||||
// yet, through the tracker's own note, link and move. The verdict note is written first. The older issue is
|
// yet, through the tracker's own note, link and move. The verdict note is written first. The older issue is
|
||||||
// noted and linked to, never moved; neither side is touched once resolved or wontfix. Answers what it did.
|
// noted and linked to, never moved; neither side is touched once resolved or wontfix. Answers what it did.
|
||||||
func (t *Tracker) apply(newer uint64, v *Verdict) ([]string, error) {
|
func (t *Tracker) apply(newer uint64, v *Verdict) ([]string, error) {
|
||||||
|
// One writer of verdicts at a time: an event's check and a confirm never both find no verdict note.
|
||||||
|
t.groom.applying.Lock()
|
||||||
|
defer t.groom.applying.Unlock()
|
||||||
is, _, err := read(t.Store, newer)
|
is, _, err := read(t.Store, newer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -371,7 +381,12 @@ func (t *Tracker) apply(newer uint64, v *Verdict) ([]string, error) {
|
|||||||
}
|
}
|
||||||
did = append(did, fmt.Sprintf("noted the verdict on %d", newer))
|
did = append(did, fmt.Sprintf("noted the verdict on %d", newer))
|
||||||
} else {
|
} else {
|
||||||
// Delivered again, or reached again by the backlog's pass: what was decided stands, in its own words.
|
// Delivered again, or reached again by the backlog's pass: what was decided stands, in its own words —
|
||||||
|
// unless the issue has moved on since (a person resolved it): then it is not touched. The move to
|
||||||
|
// wontfix is always the last step, so an issue the check closed has nothing left to do either.
|
||||||
|
if !unresolved(is) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
v = recorded(is)
|
v = recorded(is)
|
||||||
}
|
}
|
||||||
if v.Verdict == VerdictNone {
|
if v.Verdict == VerdictNone {
|
||||||
@@ -464,7 +479,12 @@ func (t *Tracker) Check(n uint64) error {
|
|||||||
older = append(older, c)
|
older = append(older, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A new issue goes before the backlog: the pass waits while an event's check waits for the agent.
|
||||||
|
t.groom.waiting.Add(1)
|
||||||
|
t.groom.judging.Lock()
|
||||||
|
t.groom.waiting.Add(-1)
|
||||||
v, err := t.judge(is, older)
|
v, err := t.judge(is, older)
|
||||||
|
t.groom.judging.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("[mesh-issues] duplicate check of %d failed: %v", n, err)
|
t.Logf("[mesh-issues] duplicate check of %d failed: %v", n, err)
|
||||||
if !noted(is, failedLine) {
|
if !noted(is, failedLine) {
|
||||||
@@ -549,6 +569,13 @@ const (
|
|||||||
type grooming struct {
|
type grooming struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
run string
|
run string
|
||||||
|
// judging is held while a judgement is asked: one at a time from this tracker, and waiting counts the
|
||||||
|
// event checks waiting for it, which the backlog's pass lets go first.
|
||||||
|
judging sync.Mutex
|
||||||
|
waiting atomic.Int32
|
||||||
|
// applying is held while a verdict is applied, and confirming while a pass is.
|
||||||
|
applying sync.Mutex
|
||||||
|
confirming sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPassID() string {
|
func newPassID() string {
|
||||||
@@ -627,11 +654,11 @@ func (t *Tracker) Groom(a GroomAsk, background func(func())) (*Groomed, error) {
|
|||||||
if !a.Confirm {
|
if !a.Confirm {
|
||||||
return &Groomed{Said: passSaid(kept), Pass: kept}, nil
|
return &Groomed{Said: passSaid(kept), Pass: kept}, nil
|
||||||
}
|
}
|
||||||
return t.applyPass(kept)
|
return t.applyPass(id)
|
||||||
}
|
}
|
||||||
if running != "" {
|
if running != "" {
|
||||||
return nil, fmt.Errorf("pass %s is running (%d of %d issues judged); one pass runs at a time: ask groom with "+
|
return nil, fmt.Errorf("pass %s is running; one pass runs at a time: ask groom with pass %s for its progress",
|
||||||
"pass %s for its progress", kept.ID, kept.Judged, kept.Issues, kept.ID)
|
running, running)
|
||||||
}
|
}
|
||||||
issues, err := t.unresolvedIssues()
|
issues, err := t.unresolvedIssues()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -656,8 +683,9 @@ func (t *Tracker) Groom(a GroomAsk, background func(func())) (*Groomed, error) {
|
|||||||
t.groom.mu.Unlock()
|
t.groom.mu.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
answered := *p
|
||||||
background(func() { t.runPass(p, issues) })
|
background(func() { t.runPass(p, issues) })
|
||||||
return &Groomed{Pass: p, Said: fmt.Sprintf("Started dry run %s over %d issues not resolved and not yet checked, oldest "+
|
return &Groomed{Pass: &answered, Said: fmt.Sprintf("Started dry run %s over %d issues not resolved and not yet checked, oldest "+
|
||||||
"first. It writes nothing on any issue. Ask groom with pass %s for its progress and findings; apply them with "+
|
"first. It writes nothing on any issue. Ask groom with pass %s for its progress and findings; apply them with "+
|
||||||
"confirm and pass %s.", p.ID, p.Issues, p.ID, p.ID)}, nil
|
"confirm and pass %s.", p.ID, p.Issues, p.ID, p.ID)}, nil
|
||||||
}
|
}
|
||||||
@@ -684,7 +712,12 @@ func (t *Tracker) runPass(p *Pass, issues []*Issue) {
|
|||||||
older = append(older, c)
|
older = append(older, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for t.groom.waiting.Load() > 0 {
|
||||||
|
t.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.groom.judging.Lock()
|
||||||
v, err := t.judge(is, older)
|
v, err := t.judge(is, older)
|
||||||
|
t.groom.judging.Unlock()
|
||||||
p.Judged++
|
p.Judged++
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
@@ -730,8 +763,27 @@ func passSaid(p *Pass) string {
|
|||||||
"on any issue; apply the findings with confirm and pass %s.", p.ID, p.Judged, len(p.Findings), len(p.Failures), p.ID)
|
"on any issue; apply the findings with confirm and pass %s.", p.ID, p.Judged, len(p.Findings), len(p.Failures), p.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPass applies exactly a done pass's findings, each checked again against the issues as they stand.
|
// applyPass applies exactly a done pass's findings, each checked again against the issues as they stand. One
|
||||||
func (t *Tracker) applyPass(p *Pass) (*Groomed, error) {
|
// confirm at a time; the pass is read again under it and kept only at the revision read, so a confirm never
|
||||||
|
// writes over a newer pass.
|
||||||
|
func (t *Tracker) applyPass(id string) (*Groomed, error) {
|
||||||
|
if !t.groom.confirming.TryLock() {
|
||||||
|
return nil, errors.New("a pass is being applied at this moment; ask again once it is done")
|
||||||
|
}
|
||||||
|
defer t.groom.confirming.Unlock()
|
||||||
|
p, rev, err := t.readPass()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p == nil || p.ID != id {
|
||||||
|
return nil, fmt.Errorf("there is no pass %s: only the last pass is kept, and a new dry run replaces it", id)
|
||||||
|
}
|
||||||
|
t.groom.mu.Lock()
|
||||||
|
running := t.groom.run
|
||||||
|
t.groom.mu.Unlock()
|
||||||
|
if p.State == PassRunning && p.ID != running {
|
||||||
|
p.State = PassInterrupted
|
||||||
|
}
|
||||||
switch p.State {
|
switch p.State {
|
||||||
case PassRunning:
|
case PassRunning:
|
||||||
return nil, fmt.Errorf("pass %s is still running; it is applied once it is done", p.ID)
|
return nil, fmt.Errorf("pass %s is still running; it is applied once it is done", p.ID)
|
||||||
@@ -751,7 +803,7 @@ func (t *Tracker) applyPass(p *Pass) (*Groomed, error) {
|
|||||||
}
|
}
|
||||||
at := t.now()
|
at := t.now()
|
||||||
p.AppliedAt, p.State = &at, PassApplied
|
p.AppliedAt, p.State = &at, PassApplied
|
||||||
if err := t.savePass(p); err != nil {
|
if _, err := t.Store.Update(GroomingState, passKey, p, rev); err != nil {
|
||||||
return nil, fmt.Errorf("the findings were applied (%s) and the pass could not be kept: %v", strings.Join(p.Applied, "; "), err)
|
return nil, fmt.Errorf("the findings were applied (%s) and the pass could not be kept: %v", strings.Join(p.Applied, "; "), err)
|
||||||
}
|
}
|
||||||
return &Groomed{Pass: p, Said: passSaid(p)}, nil
|
return &Groomed{Pass: p, Said: passSaid(p)}, nil
|
||||||
|
|||||||
@@ -441,3 +441,52 @@ func TestConfirmClosesOnlyTheNewerAndKeepsTheOlderOpen(t *testing.T) {
|
|||||||
t.Fatal("an interrupted pass was applied")
|
t.Fatal("an interrupted pass was applied")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An issue the check already judged, then resolved by a person, is not touched when its event comes again.
|
||||||
|
func TestAJudgedIssueAPersonResolvedIsNotTouchedAgain(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
older := h.open(t, "the counter skips a number")
|
||||||
|
newer := h.open(t, "a number is skipped")
|
||||||
|
if _, err := h.note(GroomBy, newer, "Duplicate check: duplicate of 365.\n\nWhy: the same."); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolve(t, h, newer)
|
||||||
|
withJudge(h, &stubJudge{err: errors.New("must not be asked")})
|
||||||
|
if err := h.Check(newer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(get(t, h, newer).Links) != 0 || len(get(t, h, older).Notes) != 0 {
|
||||||
|
t.Fatal("a resolved issue was linked, or its older noted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass runs in the background for real, while its answer is read and a new issue is checked; the race
|
||||||
|
// detector watches.
|
||||||
|
func TestThePassRunsBesideTheChecksOfNewIssues(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
h.open(t, "issue "+string(rune('a'+i)))
|
||||||
|
}
|
||||||
|
withJudge(h, &stubJudge{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
out, err := h.Groom(GroomAsk{}, func(f func()) { go func() { f(); close(done) }() })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(out) // read while the pass runs
|
||||||
|
newer := h.open(t, "a new one")
|
||||||
|
if err := h.Check(newer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
if !strings.Contains(string(raw), out.Pass.ID) {
|
||||||
|
t.Fatal(string(raw))
|
||||||
|
}
|
||||||
|
p, _, _ := h.readPass()
|
||||||
|
if p.State != PassDone || p.Judged != 6 {
|
||||||
|
t.Fatalf("%+v", p)
|
||||||
|
}
|
||||||
|
if nn := checkNotes(get(t, h, newer)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Duplicate check: none of 6 older") {
|
||||||
|
t.Fatalf("%q", nn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user