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)