From 6348b20e770e072bda8a96db4b7121f981b8e75b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:11:54 +0200 Subject: [PATCH] Say loudly when a condition that needs the operator could not be asked on any channel (hq ADR 0259) With no router, or an ask the router refused and nothing changed since, the controller asked nothing and said it only in its own log. It now keeps a condition of its own, asks-undelivered, naming the conditions not asked and why, cleared once each can be asked again. --- cmd/mesh-controller/asker.go | 65 +++++++++++++++++++++++++++++-- cmd/mesh-controller/asker_test.go | 49 +++++++++++++++++++++++ cmd/mesh-controller/asker_wire.go | 7 +++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 3c21e574..5202fc19 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -116,8 +116,11 @@ type asker struct { routerHere func(ctx context.Context) (bool, error) // channels is what the channels are now, as a fingerprint: who holds which kind, promising what. channels func(ctx context.Context) string - now func() time.Time - logf func(string, ...any) + // 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) saidNoRouter bool @@ -187,7 +190,18 @@ func (a *asker) reconcile(ctx context.Context) error { "named by its caller, assigned): the operator is asked nothing until one is", broker.AsksSeat) a.saidNoRouter = true } - return nil + open, err := a.open(ctx) + if err != nil { + return err + } + var unasked []conditions.Condition + for _, c := range open { + if wants(c, now) { + unasked = append(unasked, c) + } + } + return a.sayUnasked(ctx, unasked, "no router takes the controller's asks: no module holding "+ + broker.AsksSeat+" that takes an ask under its asker's name is assigned") } a.saidNoRouter = false } @@ -248,6 +262,8 @@ func (a *asker) reconcile(ctx context.Context) error { } } wanted := map[string]bool{} + var unasked []conditions.Condition // refused by the router, and nothing it was refused for changed + var refusedWords []string // The most urgent first, then the oldest: those are asked when no more than askMostOpen may be. sort.SliceStable(open, func(i, j int) bool { ui, uj := open[i].Severity == conditions.Urgent, open[j].Severity == conditions.Urgent @@ -272,6 +288,10 @@ func (a *asker) reconcile(ctx context.Context) error { wanted[c.Key] = true if r, was := refused[c.Key]; was && sameAsked(r.Actions, c.Actions) && r.Channels == channels { if _, held := byCondition[c.Key]; !held { + unasked = append(unasked, c) + if r.Warrant != nil && r.Warrant.Words != "" { + refusedWords = append(refusedWords, r.Warrant.Words) + } continue // refused, and nothing it was refused for has changed } } @@ -313,6 +333,45 @@ func (a *asker) reconcile(ctx context.Context) error { } } } + why := "the router refused the ask" + if len(refusedWords) > 0 { + why += ": " + refusedWords[0] + } + return a.sayUnasked(ctx, unasked, why) +} + +// sourceAsker raises the asker's own condition. +const sourceAsker = "asker" + +// sayUnasked keeps the asker's one condition: while a condition that needs the operator could not be asked +// on any channel, said loudly (failure must be loud), cleared when every one could be. +func (a *asker) sayUnasked(ctx context.Context, unasked []conditions.Condition, why string) error { + if a.raise == nil { + return nil + } + var obs []conditions.Observation + if len(unasked) > 0 { + keys := make([]string, 0, len(unasked)) + severity := conditions.Warning + for _, c := range unasked { + keys = append(keys, c.Key) + if c.Severity == conditions.Urgent { + severity = conditions.Urgent + } + } + sort.Strings(keys) + obs = append(obs, conditions.Observation{Scope: conditions.ScopeSeat, ID: broker.AsksSeat, Token: "unasked", + Kind: "asks-undelivered", Severity: severity, Source: sourceAsker, + Summary: fmt.Sprintf("%d condition(s) that need the operator could not be asked on any channel: %s; %s", + len(keys), strings.Join(keys, ", "), why), + Headline: "Questions for you not delivered", + Explanation: "Needs you: answer them from the mesh MCP server. The mesh could not send you its questions on any channel.", + Needs: "answer them from the mesh MCP server, and check why no channel carries them.", + Resolved: "The mesh can ask you again"}) + } + if err := a.raise(ctx, obs); err != nil { + a.logf("whether the operator could be asked could not be kept as a condition: %v", err) + } return nil } diff --git a/cmd/mesh-controller/asker_test.go b/cmd/mesh-controller/asker_test.go index 0edcb308..5e40c9c4 100644 --- a/cmd/mesh-controller/asker_test.go +++ b/cmd/mesh-controller/asker_test.go @@ -514,3 +514,52 @@ func TestAWarrantPerformsOnlyTheActItsOptionBound(t *testing.T) { } } } + +// Failure is loud (novox/hq ADR 0259, the self-review of 2026-10-09): a condition that needs the operator and +// could not be asked on any channel — no router, or the router refused the ask — is a condition of its own, +// cleared once it can be asked again. +func TestAnAskThatCannotBeDeliveredIsSaid(t *testing.T) { + r := newAskerRig(t) + var raised [][]conditions.Observation + r.a.raise = func(_ context.Context, obs []conditions.Observation) error { + raised = append(raised, obs) + return nil + } + last := func() []conditions.Observation { return raised[len(raised)-1] } + routerHere := false + r.a.routerHere = func(context.Context) (bool, error) { return routerHere, nil } + channels := "channel/telegram=telegram@anchor[choice]own:true" + r.a.channels = func(context.Context) string { return channels } + r.open = []conditions.Condition{heldCondition()} + + _ = r.a.reconcile(context.Background()) + if got := last(); len(got) != 1 || got[0].Kind != "asks-undelivered" || + !strings.Contains(got[0].Summary, heldCondition().Key) || !strings.Contains(got[0].Summary, "no router") { + t.Fatalf("no router, said as %+v", got) + } + + routerHere = true + _ = r.a.reconcile(context.Background()) + if got := last(); len(got) != 0 { + t.Fatalf("asked, and still said undelivered: %+v", got) + } + first := r.asksSent(t)[0] + refusal, _ := json.Marshal(asks.Warrant{Ask: first.ID, Asker: "mesh-controller", Outcome: asks.OutcomeRefused, + Words: "no channel can carry any of its answers now", At: r.now}) + if err := r.a.Decided(context.Background(), refusal); err != nil { + t.Fatal(err) + } + _ = r.a.reconcile(context.Background()) + if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "no channel can carry") { + t.Fatalf("the router's refusal, said as %+v", got) + } + if why, ok := conditions.PlainWords(conditions.Words{Headline: last()[0].Headline, Explanation: last()[0].Explanation, + Needs: last()[0].Needs, Resolved: last()[0].Resolved}, ""); !ok { + t.Errorf("not plain: %s", why) + } + r.open = nil + _ = r.a.reconcile(context.Background()) + if got := last(); len(got) != 0 { + t.Errorf("nothing needs asking, and still said: %+v", got) + } +} diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go index 9f0ae5f5..dd7ba87a 100644 --- a/cmd/mesh-controller/asker_wire.go +++ b/cmd/mesh-controller/asker_wire.go @@ -283,8 +283,11 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n routerRecord: routerRecordOf(conn, open.inventory), routerHere: routerHereIn(open.inventory), channels: channelsIn(open.inventory), - now: time.Now, - logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) }, + raise: func(ctx context.Context, obs []conditions.Observation) error { + return keeper.Reconcile(ctx, sourceAsker, obs) + }, + now: time.Now, + logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) }, } if err := server.Decides(a); err != nil { fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)