diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index 89fd485d..d32b387c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -397,7 +397,7 @@ func RecoveryTools(r check.Reader, w check.VerdictEntry) []stdio.Tool { return []stdio.Tool{{Name: check.AskAgainTool, Description: "Ask a candidate's check again by hand, whose verdict stays running while nothing runs for it (" + check.StatusReadTool + " names it " + "under lost): its verdict is ended as asked again, saying why, and the checker, which hears its verdicts, stops the wait that judged nothing " + - "and asks the check anew, whole. Refused for a group with no candidate, a verdict that is not running (a pass or failure stands until a push or " + + "and asks the check anew, whole. Refused for a group with no candidate, a verdict that is not running or a failure whose failing steps did not all end on a transport fault (a pass, or any other failure, stands until a push or " + "a trunk move), and while a step of its pull requests can still run on a build module (its end judges it). The checker reads a step that " + "ended while its watch was deaf by itself at the next step end of its build module; this is for what that does not reach.", Input: map[string]any{"type": "object", "required": []string{"group", "why"}, "properties": map[string]any{ diff --git a/modules/mesh-delivery-checker/internal/check/askagain.go b/modules/mesh-delivery-checker/internal/check/askagain.go index 0c4ea8ce..75b7ddfa 100644 --- a/modules/mesh-delivery-checker/internal/check/askagain.go +++ b/modules/mesh-delivery-checker/internal/check/askagain.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **A check is asked again by hand** (the lost check of 2026-10-28; tools-are-how-a-module-is-spoken-to: every recovery @@ -92,7 +93,7 @@ type AskedAgain struct { } // AskAgain ends the running verdict of the group's candidate as asked again by hand, saying why. It refuses a group with -// no candidate, a verdict that is not running (a pass or a failure stands until a push or a trunk move), one a step of +// no candidate, a verdict that is not running (a pass stands, and a failure unless every failing step ended on a transport fault, ADR 0664; both stand until a push or a trunk move), one a step of // whose pull requests can still run, and one whose asks are not known or one of which no node took yet. func (r Reader) AskAgain(w VerdictEntry, group, why string) (AskedAgain, error) { why = strings.TrimSpace(why) @@ -121,15 +122,26 @@ func (r Reader) AskAgain(w VerdictEntry, group, why string) (AskedAgain, error) if err != nil { return AskedAgain{}, fmt.Errorf("%s holds what is not a verdict: %w", key, err) } - if v.State != meshdeliverycheckerclient.VerdictRunning { + failed := v.State == meshdeliverycheckerclient.VerdictFail + if v.State != meshdeliverycheckerclient.VerdictRunning && !failed { return AskedAgain{}, fmt.Errorf("%s is %s, not running: a verdict stands until a push or a trunk move asks it again", key, v.State) } steps := r.readSteps() + if failed { + // ADR 0664 (operator answer 5, issue 1748): a failure stands, except one whose failing steps all ended on a transport + // fault (a step written passing): the network failed, not the change, so it is asked again like a lost check. + if err := steps.onlyTransportFaults(c); err != nil { + return AskedAgain{}, fmt.Errorf("%s is fail, and a failure stands until a push or a trunk move asks it again, unless every failing step of it ended on a transport fault: %w", key, err) + } + } for _, rs := range steps.running { if asksOf(c, rs.What) { return AskedAgain{}, fmt.Errorf("a step of it runs: %s %s on %s since %s (%s); its end judges it", rs.Module, rs.ID, rs.Node, rs.Since, rs.What) } } + if failed { + return r.askedAgain(w, key, rev, v, why) + } if r.AskState.Get == nil { return AskedAgain{}, fmt.Errorf("the checker's asks are not readable here, so whether its ask waits in a build seat's stream cannot be told") } @@ -151,6 +163,31 @@ func (r Reader) AskAgain(w VerdictEntry, group, why string) (AskedAgain, error) "the seats' backlog is in mesh-delivery-view.build_usage", id) } } + return r.askedAgain(w, key, rev, v, why) +} + +// onlyTransportFaults is nil when the candidate's pull requests have a failed or refused step and every one of them ended +// passing (buildstep.Step.Passing); else why not. +func (s stepsRead) onlyTransportFaults(c meshcatalog.CandidatesByGroupValue) error { + n := 0 + for _, ms := range s.all { + st := ms.step + if !asksOf(c, st.What) || (st.State != buildstep.StepFailed && st.State != buildstep.StepRefused) { + continue + } + if !st.Passing { + return fmt.Errorf("its step %s on %s ended %s and not on a transport fault: %s", st.ID, st.Node, st.State, st.Reason) + } + n++ + } + if n == 0 { + return errors.New("no failed step of it is read (the newest keys of each build module's steps are)") + } + return nil +} + +// askedAgain writes the verdict v ended as asked again by hand, why saying so. +func (r Reader) askedAgain(w VerdictEntry, key string, rev uint64, v meshdeliverychecker.VerdictsByGroupHeadsValue, why string) (AskedAgain, error) { words := AskedAgainWords + why ended := meshdeliverychecker.VerdictsByGroupHeadsValue{Group: v.Group, Heads: v.Heads, Trunk: v.Trunk, State: meshdeliverycheckerclient.VerdictFail, Failed: &FailedFirst{Machine: "checker", Words: words}} diff --git a/modules/mesh-delivery-checker/internal/check/lostask_test.go b/modules/mesh-delivery-checker/internal/check/lostask_test.go index 00d6fa78..57f3b802 100644 --- a/modules/mesh-delivery-checker/internal/check/lostask_test.go +++ b/modules/mesh-delivery-checker/internal/check/lostask_test.go @@ -3,6 +3,9 @@ package check import ( "context" "encoding/json" + "fmt" + "maps" + "slices" "strings" "sync" "testing" @@ -347,7 +350,40 @@ func TestACheckWhoseAskJudgedNothingIsAskedAgainOrRead(t *testing.T) { if _, err := rd.AskAgain(asVerdicts{r.verdicts}, "feat/x", "again"); err == nil { t.Error("a judged check was asked again") } + // ADR 0664 (issue 1748, operator answer 5): a failed verdict whose failing steps all ended on a transport fault + // is asked again; one with a failing step that did not, or with none read, stands. + for _, c := range []struct { + name string + passing []bool + ok bool + }{{"every failing step on a transport fault", []bool{true, true}, true}, {"one step failed on the source", []bool{true, false}, false}, {"no failed step read", nil, false}} { + failedKV := newMemKV() + judgedRaw, _, _, _ := r.verdicts.Get(ctx, key) + failed, _ := meshdeliverycheckerclient.DecodeVerdict(judgedRaw) + failed.State, failed.Failed = meshdeliverycheckerclient.VerdictFail, &FailedFirst{Machine: "ace", Words: "a transport fault on 3 runs"} + raw, _ := meshdeliverycheckerclient.EncodeVerdict(failed) + failedKV.Create(ctx, key, raw) + vals := map[string]json.RawMessage{} + for i, p := range c.passing { + id := fmt.Sprint("mvl0frv18d19", 80+i) + vals["ace."+id], _ = buildstep.EncodeStep(buildstep.Step{ID: id, Node: "ace", Tool: "check ask", State: buildstep.StepFailed, Passing: p, Ended: time.Now(), What: "check " + repoA + "@" + short12(head1)}) + } + rd := Reader{CandidateState: kvRead(cands2(t, cand)), VerdictState: kvRead(failedKV), AskState: kvRead(newMemKV()), StepStates: map[string]StateRead{buildrun.GoBuildModule: { + Keys: func() ([]string, error) { return slices.Collect(maps.Keys(vals)), nil }, Get: func(k string) (json.RawMessage, error) { return vals[k], nil }}}} + if got, err := rd.AskAgain(asVerdicts{failedKV}, "feat/x", "the network"); (err == nil) != c.ok || (c.ok && !strings.HasPrefix(got.Said, AskedAgainWords)) { + t.Errorf("%s: asked again = %+v, %v, want asked again %v", c.name, got, err, c.ok) + } + } } }) } } + +// cands2 is a candidates state holding cand under feat/x. +func cands2(t *testing.T, cand []byte) *memKV { + t.Helper() + kv := newMemKV() + ckey, _ := meshcatalogclient.CandidatesKey("feat/x") + _, _ = kv.Create(context.Background(), ckey, cand) + return kv +}