Tell the operator when the answer to a plan's ask is refused (issue 1316, ADR 0449)

This commit is contained in:
2026-10-19 21:30:33 +00:00
parent 7770d193da
commit 12db1d3748
3 changed files with 138 additions and 2 deletions
+35 -2
View File
@@ -208,8 +208,11 @@ type asker struct {
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be // 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). // asked, and why. Nil raises nothing (a test that does not look).
raise func(ctx context.Context, obs []conditions.Observation) error raise func(ctx context.Context, obs []conditions.Observation) error
now func() time.Time // tell tells the operator one plain notice that needs no answer (operator-channel.tell, novox/hq ADR 0380):
logf func(string, ...any) // 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 saidNoRouter bool
saidNoGrant bool saidNoGrant bool
@@ -886,6 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
ended := a.now() ended := a.now()
if acted != nil { if acted != nil {
outcome = "failed: " + acted.Error() outcome = "failed: " + acted.Error()
a.tellRefused(ctx, *r, act, acted)
} }
r.Ended, r.Acted = ended, outcome r.Ended, r.Acted = ended, outcome
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { 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 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, // handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
// never a repair (handActVerbs). // never a repair (handActVerbs).
const handActWarrant = "warrant" const handActWarrant = "warrant"
+86
View File
@@ -77,6 +77,15 @@ type askerRig struct {
silenced []string silenced []string
acts []link.HandAct acts []link.HandAct
now time.Time 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 { 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 { call: func(_ context.Context, a conditions.Action, args map[string]string) error {
raw, _ := json.Marshal(args) raw, _ := json.Marshal(args)
r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw)) 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 return nil
}, },
record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); 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) 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)
}
}
+17
View File
@@ -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 // 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. // 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) { 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) _, err := link.RecordHandAct(ctx, conn, act)
return err return err
}, },
tell: tellThrough(conn),
routerRecord: routerRecordOf(conn, open.inventory), routerRecord: routerRecordOf(conn, open.inventory),
routerHere: routerHereIn(open.inventory), routerHere: routerHereIn(open.inventory),
grantHeld: grantHeldIn(open, conn), grantHeld: grantHeldIn(open, conn),