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.
This commit is contained in:
@@ -116,8 +116,11 @@ type asker struct {
|
|||||||
routerHere func(ctx context.Context) (bool, error)
|
routerHere func(ctx context.Context) (bool, error)
|
||||||
// channels is what the channels are now, as a fingerprint: who holds which kind, promising what.
|
// channels is what the channels are now, as a fingerprint: who holds which kind, promising what.
|
||||||
channels func(ctx context.Context) string
|
channels func(ctx context.Context) string
|
||||||
now func() time.Time
|
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
||||||
logf func(string, ...any)
|
// 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
|
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)
|
"named by its caller, assigned): the operator is asked nothing until one is", broker.AsksSeat)
|
||||||
a.saidNoRouter = true
|
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
|
a.saidNoRouter = false
|
||||||
}
|
}
|
||||||
@@ -248,6 +262,8 @@ func (a *asker) reconcile(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
wanted := map[string]bool{}
|
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.
|
// 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 {
|
sort.SliceStable(open, func(i, j int) bool {
|
||||||
ui, uj := open[i].Severity == conditions.Urgent, open[j].Severity == conditions.Urgent
|
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
|
wanted[c.Key] = true
|
||||||
if r, was := refused[c.Key]; was && sameAsked(r.Actions, c.Actions) && r.Channels == channels {
|
if r, was := refused[c.Key]; was && sameAsked(r.Actions, c.Actions) && r.Channels == channels {
|
||||||
if _, held := byCondition[c.Key]; !held {
|
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
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -283,8 +283,11 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
|
|||||||
routerRecord: routerRecordOf(conn, open.inventory),
|
routerRecord: routerRecordOf(conn, open.inventory),
|
||||||
routerHere: routerHereIn(open.inventory),
|
routerHere: routerHereIn(open.inventory),
|
||||||
channels: channelsIn(open.inventory),
|
channels: channelsIn(open.inventory),
|
||||||
now: time.Now,
|
raise: func(ctx context.Context, obs []conditions.Observation) error {
|
||||||
logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
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 {
|
if err := server.Decides(a); err != nil {
|
||||||
fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)
|
fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user