From 45db0e650e3163d86977542907f86cd907911277 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 22:27:23 +0100 Subject: [PATCH] Record a drill in mesh-hand-acts and count S15 over both logs, so the healers no longer refuse a drill (issue 1953) --- modules/mesh-healers/README.md | 5 +- .../mesh-healers/cmd/mesh-healers/healers.go | 3 + .../cmd/mesh-healers/healers_test.go | 65 ++++++++++++++++--- modules/mesh-healers/cmd/mesh-healers/main.go | 14 ++++ .../cmd/mesh-healers/manifest_test.go | 4 +- .../mesh-healers/cmd/mesh-healers/tools.go | 49 ++++++++++++-- .../mesh-healers/cmd/mesh-healers/wanted.go | 12 ++++ modules/mesh-healers/module.json | 7 +- 8 files changed, 137 insertions(+), 22 deletions(-) diff --git a/modules/mesh-healers/README.md b/modules/mesh-healers/README.md index 30aac2c8..d00cdb1b 100644 --- a/modules/mesh-healers/README.md +++ b/modules/mesh-healers/README.md @@ -25,7 +25,7 @@ again, `fortnight` ends when S15's oldest counted act leaves the fortnight and c each expiry; no timer runs in the process. A wait still running is on the bus across a restart; one that expired while no instance watched is judged at the start, once the lease is first read. -S15: it watches the controller's hand-act log and writes `healer-wanted` for a cause repaired by hand twice within a +S15: it hears the seat mesh-hand-acts' `act-recorded` and watches the controller's hand-act log and writes `healer-wanted` for a cause repaired by hand twice within a fortnight; a drill and a person's decision by design never count. ## What it keeps @@ -41,5 +41,6 @@ fortnight; a drill and a person's decision by design never count. - `mesh-healers.healers`: the registry, the acts of the last days, the would-acts, the brake, whether it acts and why; `recount=true` counts the hand-act log again. - `mesh-healers.heal`: one act now on an open condition, by key and why. -- `mesh-healers.drill`: refused until the hand-act log's home is delivered (issue 1953), naming `mesh-controller.drill`. +- `mesh-healers.drill`: records a drill in the hand-act log of the seat `mesh-hand-acts` as `hand-act drill` (the + controller's `drill` while that seat has no holder and the controller holds its lease). S15 never counts a drill. - `healers_findings`, `healers_would`, `healers_wakes`: the reads of its buckets. diff --git a/modules/mesh-healers/cmd/mesh-healers/healers.go b/modules/mesh-healers/cmd/mesh-healers/healers.go index d871de01..425eba47 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers.go @@ -27,6 +27,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -48,6 +49,8 @@ const ( ActedEvent = health.EventHealerActed // ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays). ActedSubject = "mesh.seat." + Seat + ".event." + ActedEvent + // RecordedEvent is the hand-act log's seat event, each act recorded there (ADR 0565 §11). + RecordedEvent = handacts.Seat + "." + handacts.EventRecorded // The events it hears: the keeper's, on the seat mesh-conditions. RaisedEvent = "mesh-conditions.condition-raised" ChangedEvent = "mesh-conditions.condition-changed" diff --git a/modules/mesh-healers/cmd/mesh-healers/healers_test.go b/modules/mesh-healers/cmd/mesh-healers/healers_test.go index 16ea85d3..9b6f9dce 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -379,9 +380,14 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) { if keys := w.store.keys(FindingsState); len(keys) != 0 { t.Fatalf("one act is a finding: %v", keys) } - act("a2", "broker consumer-reset", "consumer-behind", 24*time.Hour) + // The second of the cause is the seat's own act, heard as act-recorded: one in each log counts two (ADR 0565 §11). + seatAct, _ := json.Marshal(HandAct{ID: "a2", At: w.now.Add(-24 * time.Hour), By: "person.jochen", + Verb: "broker consumer-reset", Why: "x", Cause: "consumer-behind"}) + w.h.ActRecorded(seatAct) + // A drill in each log counts none. act("a3", "hand-act drill", "drill", time.Hour) - act("a4", "hand-act drill", "drill", 2*time.Hour) + drill, _ := json.Marshal(HandAct{ID: "a4", At: w.now.Add(-2 * time.Hour), Verb: "hand-act drill", Why: "x", Cause: "drill"}) + w.h.ActRecorded(drill) act("a5", "conditions silence", "operator-answer", time.Hour) act("a6", "conditions silence", "operator-answer", time.Hour) act("a7", "hand-act record", "relogin", 20*24*time.Hour) @@ -402,7 +408,7 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) { if keys := w.store.keys(FindingsState); len(keys) != 0 { t.Errorf("past the fortnight the finding stays: %v", keys) } - w.h.HandActHeard("a2", "delete", nil) + w.h.HandActHeard("a5", "delete", nil) if len(w.h.acts) != 7 { t.Errorf("a deleted entry is kept: %d", len(w.h.acts)) } @@ -436,14 +442,53 @@ func TestTheVerbsAndReads(t *testing.T) { if a["acting"] != true || len(a["acts"].([]Attempt)) != 1 || len(a["healers"].([]map[string]any)) != 3 { t.Errorf("healers answered %v", a) } - var drill error - for _, tool := range Tools(w.h) { - if tool.Name == DrillTool { - _, drill = tool.Run(map[string]any{}) - } + // drill records into mesh-hand-acts as a drill; while that seat has no holder, into the controller's drill until + // the lease is handed away (ADR 0565 §11). + drills := []struct { + name string + seatGone bool + leaseAway bool + args map[string]any + log, err string + }{ + {"into the seat", false, true, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""}, + {"no holder: the controller's", true, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogController, ""}, + {"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "handed its lease away"}, + {"says nothing", false, true, map[string]any{}, "", "says what"}, } - if drill == nil || !strings.Contains(drill.Error(), "mesh-controller.drill") { - t.Errorf("drill answered %v", drill) + for _, d := range drills { + x := newWorld() + if d.leaseAway { + x.handAway() + } else { + x.h.LeaseHeard([]byte(`{"instance":"novox/1","epoch":5}`), true, nil) + } + var asked []string + x.h.Ask = func(key string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + asked = append(asked, key+" "+string(raw)) + if d.seatGone && strings.HasPrefix(key, "seat:mesh-hand-acts.") { + return nil, errors.New("nats: no responders available for request") + } + return json.RawMessage(`{}`), nil + } + var got any + var err error + for _, tool := range Tools(x.h) { + if tool.Name == DrillTool { + got, err = tool.Run(d.args) + } + } + switch { + case d.err != "": + if err == nil || !strings.Contains(err.Error(), d.err) { + t.Errorf("drill %s: %v", d.name, err) + } + case err != nil || got.(map[string]any)["log"] != d.log: + t.Errorf("drill %s: %v, %v", d.name, got, err) + case d.log == handacts.LogSeat && !strings.Contains(asked[0], `"verb":"hand-act drill"`): + t.Errorf("drill %s asked %v", d.name, asked) + } } if _, err := w.h.read(WouldState, ""); err != nil { t.Error(err) diff --git a/modules/mesh-healers/cmd/mesh-healers/main.go b/modules/mesh-healers/cmd/mesh-healers/main.go index 7165f20b..6e48bfba 100644 --- a/modules/mesh-healers/cmd/mesh-healers/main.go +++ b/modules/mesh-healers/cmd/mesh-healers/main.go @@ -84,6 +84,20 @@ func main() { h.HandActHeard(c.Key, c.Op, c.Value) return nil }) + // S15 over both logs: the seat's act-recorded on a durable consumer, beside the controller's bucket's watch. + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + time.Sleep(wait) + err := stdio.Subscribe(RecordedEvent, func(e stdio.Envelope) error { + h.ActRecorded(e.Body) + return nil + }) + if err == nil { + return + } + logf("[%s] does not hear %s yet (%v); asking again in %s", Module, RecordedEvent, err, wait) + } + }() for _, event := range []string{RaisedEvent, ChangedEvent} { go func(event string) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { diff --git a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go index 08d16430..af3745b9 100644 --- a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go @@ -48,8 +48,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { got, want []string }{ {"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil}, - {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent}}, - {"invokes", m.Invokes, []string{AssertTool}}, + {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}}, + {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-controller.drill"}}, {"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}}, } { if !slices.Equal(c.got, c.want) { diff --git a/modules/mesh-healers/cmd/mesh-healers/tools.go b/modules/mesh-healers/cmd/mesh-healers/tools.go index 3dd70607..d3452b95 100644 --- a/modules/mesh-healers/cmd/mesh-healers/tools.go +++ b/modules/mesh-healers/cmd/mesh-healers/tools.go @@ -13,6 +13,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" ) // Verbs are the seat's verbs, as the manifest claims them. @@ -55,12 +56,14 @@ func Tools(h *Healers) []stdio.Tool { return h.Heal(strArg(a, "key"), strArg(a, "why"), stdio.CallerOf(a)) }}, {Name: DrillTool, - Description: "A drill: something broken on purpose to see the mesh raise and clear it, recorded in the hand-act " + - "log. Refused until the log's home is delivered (issue 1953): the controller's drill records it meanwhile.", - Input: obj(map[string]any{"why": map[string]any{"type": "string"}}), - Run: func(map[string]any) (any, error) { - return nil, errors.New("a drill is recorded in the hand-act log, whose one writer is the controller until its " + - "home is delivered (issue 1953): use mesh-controller.drill, which records it and S15 never counts") + Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it " + + "tests, in the hand-act log of the seat mesh-hand-acts (the controller's drill while that seat has no " + + "holder and the controller holds its lease). A drill is never a repair: S15 never counts it.", + Input: obj(map[string]any{"what": map[string]any{"type": "string", "description": "what was done on purpose"}, + "why": map[string]any{"type": "string", "description": "what the drill tests"}, + "condition": map[string]any{"type": "string", "description": "the condition it is meant to raise, if any"}}), + Run: func(a map[string]any) (any, error) { + return h.Drill(strArg(a, "what"), strArg(a, "why"), strArg(a, "condition")) }}, {Name: FindingsTool, Description: "What the healers find of themselves (their bucket findings): the brake, their blindness, and " + @@ -81,6 +84,40 @@ func Tools(h *Healers) []stdio.Tool { } } +// Drill records a drill (ADR 0565 §11, which ends ADR 0564 Limits 1's refusal): through the seat mesh-hand-acts as +// the act `hand-act drill`, and while that seat has no holder that answers and the controller still holds its lease, +// through the controller's own `drill`, which records it as a drill too. It answers which log took it. +func (h *Healers) Drill(what, why, condition string) (any, error) { + if what == "" || why == "" { + return nil, errors.New("a drill says what was done on purpose and what it tests: nothing was recorded") + } + if h.Ask == nil { + return nil, errors.New("the hand-act log cannot be reached from here: nothing was recorded") + } + body := map[string]any{"what": what, "why": why, "verb": handacts.VerbDrill} + controller := map[string]any{"what": what, "why": why} + if condition != "" { + body["condition"], controller["condition"] = condition, condition + } + answer, err := h.Ask("seat:"+handacts.Seat+".hand-act", body) + if err == nil { + return map[string]any{"log": handacts.LogSeat, "answer": answer}, nil + } + if !handacts.NoResponder(err) { + return nil, fmt.Errorf("%s.hand-act refused the drill: %w", handacts.Seat, err) + } + if acting, _ := h.Acting(); acting { + return nil, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing was "+ + "recorded (%w)", handacts.Seat, err) + } + answer, cerr := h.Ask("seat:mesh-controller.drill", controller) + if cerr != nil { + return nil, fmt.Errorf("%s has no holder that answers (%v), and the controller's drill refused too: %w", handacts.Seat, + err, cerr) + } + return map[string]any{"log": handacts.LogController, "answer": answer}, nil +} + // Heal is the verb heal: one condition judged now by its healer. func (h *Healers) Heal(key, why, caller string) (any, error) { if key == "" || why == "" { diff --git a/modules/mesh-healers/cmd/mesh-healers/wanted.go b/modules/mesh-healers/cmd/mesh-healers/wanted.go index 0c91821e..065f2c41 100644 --- a/modules/mesh-healers/cmd/mesh-healers/wanted.go +++ b/modules/mesh-healers/cmd/mesh-healers/wanted.go @@ -15,6 +15,8 @@ import ( "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/handacts" ) // Within is how far back a repeated cause counts. @@ -66,6 +68,16 @@ func personsDecision(a HandAct) bool { return len(causes) == 0 || slices.Contains(causes, a.Cause) } +// ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded: counted beside the controller's +// log's, under a key of its own log, so an act is counted once whichever log holds it (ADR 0565 §11). +func (h *Healers) ActRecorded(body []byte) { + var a HandAct + if json.Unmarshal(body, &a) != nil || a.ID == "" { + return + } + h.HandActHeard(handacts.LogSeat+":"+a.ID, "put", body) +} + // HandActHeard is one change of the hand-act log: kept or forgotten, and every cause counted again. func (h *Healers) HandActHeard(key, op string, value []byte) { h.mu.Lock() diff --git a/modules/mesh-healers/module.json b/modules/mesh-healers/module.json index 4319f46f..634dd388 100644 --- a/modules/mesh-healers/module.json +++ b/modules/mesh-healers/module.json @@ -15,10 +15,13 @@ ], "consumes": [ "mesh-conditions.condition-raised", - "mesh-conditions.condition-changed" + "mesh-conditions.condition-changed", + "mesh-hand-acts.act-recorded" ], "invokes": [ - "mesh-grants.grants_assert" + "mesh-grants.grants_assert", + "seat:mesh-hand-acts.hand-act", + "seat:mesh-controller.drill" ], "reads": [ "mesh-controller.lease",