From 12db1d37489f5f95f58aaa7ef286e6ca3e7a4ee1 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:30:33 +0000 Subject: [PATCH] Tell the operator when the answer to a plan's ask is refused (issue 1316, ADR 0449) --- cmd/mesh-controller/asker.go | 37 ++++++++++++- cmd/mesh-controller/asker_test.go | 86 +++++++++++++++++++++++++++++++ cmd/mesh-controller/asker_wire.go | 17 ++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 24ca3296..76ac9a1f 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -208,8 +208,11 @@ type asker struct { // raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be // asked, and why. Nil raises nothing (a test that does not look). raise func(ctx context.Context, obs []conditions.Observation) error - now func() time.Time - logf func(string, ...any) + // tell tells the operator one plain notice that needs no answer (operator-channel.tell, novox/hq ADR 0380): + // the refusal of an answer to a plan's ask (ADR 0449 §3). Nil tells nothing (a test that does not look). + tell func(ctx context.Context, headline string, lines []string) error + now func() time.Time + logf func(string, ...any) saidNoRouter bool saidNoGrant bool @@ -886,6 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { ended := a.now() if acted != nil { outcome = "failed: " + acted.Error() + a.tellRefused(ctx, *r, act, acted) } r.Ended, r.Acted = ended, outcome if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { @@ -920,6 +924,35 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { return nil } +// tellRefused tells the operator that the answer they gave to a plan's ask was refused (novox/hq ADR 0449 §3): the +// controller's `plans` verb refuses a Retry, Start or Stop when the plan moved on between the ask and the answer, +// and the refusal was only recorded, so the operator who pressed the answer heard nothing. One plain notice with the +// ask's headline and the refusal's words, beside the hand act the caller still records. An answer to any other ask +// is not told here. A notice that cannot be told is said in the log and changes nothing. +func (a *asker) tellRefused(ctx context.Context, r asked, act conditions.Action, refusal error) { + if a.tell == nil || act.Verb != broker.ControllerSeat+".plans" { + return + } + lines := []string{ + fmt.Sprintf("Your answer, %s, could not be done: %s.", act.Label, strings.TrimRight(refusalWords(refusal), ".")), + "Nothing was changed.", + } + if err := a.tell(ctx, r.Ask.Headline, lines); err != nil { + a.logf("the operator could NOT be told that %s was refused (ask %s): %v", act.Label, r.ID, err) + } +} + +// refusalWords is a refused verb's words as the operator reads them: without the verb's address in front, with the +// plans named as "the plan" (plainRefusal), and, where they still carry an identifier the operator's channel +// withholds, only that the controller refused it. +func refusalWords(err error) string { + text := err.Error() + if i := strings.Index(text, " refused: "); i >= 0 { + text = text[i+len(" refused: "):] + } + return plainRefusal(text) +} + // handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision, // never a repair (handActVerbs). const handActWarrant = "warrant" diff --git a/cmd/mesh-controller/asker_test.go b/cmd/mesh-controller/asker_test.go index 71066b70..031ad89d 100644 --- a/cmd/mesh-controller/asker_test.go +++ b/cmd/mesh-controller/asker_test.go @@ -77,6 +77,15 @@ type askerRig struct { silenced []string acts []link.HandAct now time.Time + // refuses, when set, is what every verb the asker calls answers; told is every notice the operator was told. + refuses error + told []told +} + +// told is one plain notice the asker told the operator. +type told struct { + headline string + lines []string } func newAskerRig(t *testing.T) *askerRig { @@ -102,6 +111,10 @@ func newAskerRig(t *testing.T) *askerRig { call: func(_ context.Context, a conditions.Action, args map[string]string) error { raw, _ := json.Marshal(args) r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw)) + return r.refuses + }, + tell: func(_ context.Context, headline string, lines []string) error { + r.told = append(r.told, told{headline, lines}) return nil }, record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); return nil }, @@ -1074,3 +1087,76 @@ func TestAWarrantForRetryCallsPlansRetryOnceAndIsRecorded(t *testing.T) { t.Errorf("kept %+v", got) } } + +// 1316/AC1 and AC2: a Retry or a Stop on a plan that moved on is refused by `plans`; the operator who pressed it is +// told once, with the ask's headline and the refusal's words, and the hand act records the failure as before. +func TestARefusedAnswerToAPlansAskIsToldOnceAndStillRecorded(t *testing.T) { + plan := "plan-1792433168676870614" + waiting := conditions.Condition{Key: "plan." + plan + ".waiting", Severity: conditions.Urgent, + Headline: "openrazer delivery waiting to start", Needs: "start it, or stop it.", + Explanation: "Needs you: start it, or stop it.", Actions: waitingActions(plan, conditions.Urgent)} + for _, tc := range []struct { + name string + c conditions.Condition + label string + }{ + {"retry", failedPlanCondition(plan), "Retry"}, + {"stop", waiting, "Stop"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newAskerRig(t) + r.refuses = errors.New("mesh-controller.plans refused: " + plan + " is done; there is nothing to retry") + r.open = []conditions.Condition{tc.c} + _ = r.a.reconcile(context.Background()) + w := r.warrantFor(t, tc.c.Key, tc.label) + answerWith(t, r, w) + answerWith(t, r, w) // heard again: told once + if len(r.told) != 1 { + t.Fatalf("told %+v", r.told) + } + got := r.told[0] + if got.headline != tc.c.Headline { + t.Errorf("the headline is %q, not the ask's %q", got.headline, tc.c.Headline) + } + said := strings.Join(got.lines, " ") + if !strings.Contains(said, "the plan is done; there is nothing to retry") || !strings.Contains(said, tc.label) { + t.Errorf("the lines do not carry the answer and the refusal's words: %q", said) + } + if why, ok := conditions.Plain(said); !ok { + t.Errorf("the notice is not plain, so the channel would withhold it: %s: %q", why, said) + } + if len(r.acts) != 1 || !strings.HasPrefix(r.acts[0].Outcome, "failed: ") { + t.Errorf("the failure is not recorded as before: %+v", r.acts) + } + if kept := r.store[w.Ask]; !strings.HasPrefix(kept.Acted, "failed: ") { + t.Errorf("kept %+v", kept) + } + }) + } +} + +// 1316/AC3: an answer that is done tells nothing; and a refusal of an answer that is no plan's is not told here. +func TestADoneAnswerTellsNothingAndOnlyAPlansRefusalIsTold(t *testing.T) { + r := newAskerRig(t) + c := failedPlanCondition("plan-1792433168676870614") + r.open = []conditions.Condition{c} + _ = r.a.reconcile(context.Background()) + answerWith(t, r, r.warrantFor(t, c.Key, "Retry")) + if len(r.told) != 0 { + t.Fatalf("a retry that was done told %+v", r.told) + } + + r = newAskerRig(t) + r.refuses = errors.New("node-service-manager.restart refused: unit not found") + module := conditions.Condition{Key: "module.openrazer.g14.unhealthy", Severity: conditions.Warning, + Headline: "openrazer not working on g14", Needs: "restart its service openrazer-daemon on g14.", + Explanation: "Needs you: restart it.", Actions: []conditions.Action{{Label: "Restart", + Verb: "node-service-manager.restart", Machine: "g14", Level: conditions.LevelApprove, + Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}} + r.open = []conditions.Condition{module} + _ = r.a.reconcile(context.Background()) + answerWith(t, r, r.warrantFor(t, module.Key, "Restart")) + if len(r.told) != 0 || len(r.acts) != 1 { + t.Fatalf("a refused restart told %+v, acts %+v", r.told, r.acts) + } +} diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go index d6ba3be5..ba057308 100644 --- a/cmd/mesh-controller/asker_wire.go +++ b/cmd/mesh-controller/asker_wire.go @@ -174,6 +174,22 @@ func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action, } } +// tellThrough tells the operator one plain notice through the operator channel's `tell`, over the controller's own +// connection and grant (novox/hq ADR 0380); a holder's refusal is an error naming it. +func tellThrough(conn *nats.Conn) func(ctx context.Context, headline string, lines []string) error { + return func(ctx context.Context, headline string, lines []string) error { + answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", + map[string]any{"headline": headline, "lines": lines}, tellWithin) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("operator-channel.tell refused: %s", answer.Error) + } + return nil + } +} + // routerRecordOf reads the router's record of one of the controller's asks, under its name, and answers // how it ended when it did: the bucket is the one the asks seat's declarer names as its records. func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.Context, id string) (*asks.Warrant, error) { @@ -576,6 +592,7 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper _, err := link.RecordHandAct(ctx, conn, act) return err }, + tell: tellThrough(conn), routerRecord: routerRecordOf(conn, open.inventory), routerHere: routerHereIn(open.inventory), grantHeld: grantHeldIn(open, conn),