From 1fef458c5f195d6778ee9c09b21c8faab887c7a1 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 21:23:39 +0100 Subject: [PATCH 1/2] Send the controller only the arguments its contract declares, and make a refused call a condition (issue 774) The assigner added "why" to every assign, unassign, pin and unpin since 2026-10-28; the controller's seat contract takes no such argument, so it refused each one and did nothing. No approved placement reached the controller's assignments, no bus account was minted for a newly placed module, and the refusal was only a journal line. Quick fix: drop "why" from ControllerCalls. Thorough fix: a contract test that every argument ControllerCalls gives, for every verb, is declared (and every required one given) in catalogue.ControllerVerbs, plus an urgent condition per pair whose call was refused (verb, node, module, the controller's words), cleared when a later call is answered or the two sources agree. Chosen: both here; a caller-side check of arguments against the seat's contract for every seat call belongs in mesh-sdk and is noted for the lead. The warrant stays in the assigner's own record (placement-changed, the hand-act log). Every module pins mesh-sdk eff81f1220e2 (in review). --- .../cmd/mesh-delivery-assigner/pass.go | 7 +- .../cmd/mesh-delivery-assigner/pass_test.go | 69 +++++++++++ .../cmd/mesh-delivery-assigner/seat.go | 114 +++++++++++++++++- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 4 +- .../internal/assign/seat.go | 60 +++++++-- .../internal/assign/seat_test.go | 89 +++++++++++++- modules/mesh-delivery-builder/go.mod | 2 +- modules/mesh-delivery-builder/go.sum | 4 +- modules/mesh-delivery-checker/go.mod | 2 +- modules/mesh-delivery-checker/go.sum | 4 +- modules/mesh-delivery-forge-bridge/go.mod | 2 +- modules/mesh-delivery-forge-bridge/go.sum | 4 +- modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 4 +- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 4 +- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 4 +- modules/mesh-delivery-watchman/go.mod | 2 +- modules/mesh-delivery-watchman/go.sum | 4 +- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 4 +- 23 files changed, 348 insertions(+), 45 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 93a28ff..76d1583 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -58,6 +58,9 @@ type passer struct { // nodeFailed is, by node and module, the words of the condition last raised for a failure that is the node's own (ADR 0557 // §1, issue 1496): raised once per words, cleared once when it ends. nodeFailed map[string]string + // callSaid is, by the key of a refused controller call's condition, what this process raised (issue 774); nil until the + // first pass after a start, which clears what a previous process raised for a pair the two sources agree on. + callSaid map[string]*callSaid // The seat mesh-placement's holder (seat.go, ADR 0567): its calls of the controller (nil: none is made), how it says // the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base. seat *seatActs @@ -148,7 +151,9 @@ func (p *passer) wants(ctx context.Context) { defer p.unplacedFindings() defer p.seatHolders(ctx) defer p.policySplits(manifests, chosen) - frozen := p.sayPlacement(p.withCalls(p.w.placementDiffers())) + differ := p.withCalls(p.w.placementDiffers()) + p.sayCalls(differ) + frozen := p.sayPlacement(differ) placed := p.w.placedNodes() puts := p.putsByModule() p.staleAsks(ctx, trunk) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index affbf2e..bed2ef1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -971,3 +971,72 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { t.Errorf("a pass after the move dropped the yes: %+v", got) } } + +// refusingBus is the assigner's process account against a controller that refuses the calls whose verb is in refuse. +type refusingBus struct { + fakeBus + refuse map[string]string +} + +func (b *refusingBus) Call(key string, body any) (json.RawMessage, error) { + for verb, words := range b.refuse { + if strings.HasSuffix(key, "."+verb) { + return nil, errors.New(words) + } + } + return json.RawMessage(`{"ok":true}`), nil +} + +// Issue 774: a call of the controller it refuses is a condition, never only a journal line: raised once, urgent, naming the verb, +// the node, the module and the refusal; cleared when a later call for the pair is answered, or, for a module's place, once the +// pair differed and the two sources agree. +func TestARefusedControllerCallIsACondition(t *testing.T) { + p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC)) + var conds []asks.Condition + p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } + bus := &refusingBus{refuse: map[string]string{"assign": `assign takes no argument "why" — it takes "build", "module", "node"; nothing was done`}} + woke := make(chan struct{}, 4) + p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false }) + assignWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}} + call := func(c assign.ControllerCall) { + p.seat.callAll([]assign.ControllerCall{c}) + <-woke + } + differ := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}} + + call(assignWeb) + p.sayCalls(differ) + p.sayCalls(differ) + if len(conds) != 1 || conds[0].State != asks.StateRaised || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" || + !strings.Contains(conds[0].Explanation, `takes no argument "why"`) || !strings.Contains(conds[0].Headline, "assign web on anchor") { + t.Fatalf("after a refused assign, said = %+v", conds) + } + + bus.refuse = nil + call(assignWeb) + p.sayCalls(nil) + if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != conds[0].Key { + t.Fatalf("after the assign is answered, said = %+v", conds) + } + + // Refused again, then assigned by hand: cleared once the pair, seen differing, agrees; not before it was seen differing. + bus.refuse = map[string]string{"assign": "refused"} + call(assignWeb) + p.sayCalls(nil) + if len(conds) != 3 || conds[2].State != asks.StateRaised { + t.Fatalf("a second refusal is not raised: %+v", conds) + } + p.sayCalls(nil) + if len(conds) != 3 { + t.Fatalf("cleared before the pair was seen differing: %+v", conds) + } + p.sayCalls(differ) + p.sayCalls(nil) + if len(conds) != 4 || conds[3].State != asks.StateCleared || !strings.Contains(conds[3].Resolved, "agree") { + t.Fatalf("the hand assign does not clear it: %+v", conds) + } + p.sayCalls(nil) + if len(conds) != 4 { + t.Fatalf("said again after the clear: %+v", conds) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index 7ba0b4d..7de5f98 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -68,10 +68,19 @@ type seatActs struct { export map[string]exportCheck // calls is, by pair (node and module, or node and provision for a pin), what the last call for it came to. calls map[string]string + // outcomes is, by the key of a call's condition, the last call made for its pair once answered or refused: refused + // "" when the controller answered it (issue 774: a refused call is a condition, never only a journal line). + outcomes map[string]callOutcome // rec records the seat's acts in the hand-act log (ADR 0565 §11). rec handacts.Recorder } +// callOutcome is what one call of the controller came to: the call, and the controller's refusal ("" when it answered). +type callOutcome struct { + call assign.ControllerCall + refused string +} + // exportCheck is the export read again for one proposal: running, its answer, or why it was not read. type exportCheck struct { running bool @@ -81,7 +90,7 @@ type exportCheck struct { func newSeatActs(bus caller, wake func(), logf func(string, ...any), leaseAway func() bool) *seatActs { return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, calls: map[string]string{}, - rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}} + outcomes: map[string]callOutcome{}, rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}} } // exportOf is the export read again for a proposal, asked once in the background. @@ -160,18 +169,115 @@ func (a *seatActs) callAll(calls []assign.ControllerCall) { go func() { defer a.wake() for _, c := range calls { - words := c.Key() + " answered" + words, refused := c.Key()+" answered", "" if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+"."+c.Verb, c.Args); err != nil { - words = c.Key() + " refused: " + err.Error() + refused = err.Error() + words = c.Key() + " refused: " + refused } a.logf("keeping the controller in step with placement: %s", words) a.mu.Lock() a.calls[c.Node+"."+c.Module] = words + a.outcomes[assign.CallConditionKey(c)] = callOutcome{call: c, refused: refused} a.mu.Unlock() } }() } +// outcomesNow is a copy of every call's last outcome, by the key of its condition. +func (a *seatActs) outcomesNow() map[string]callOutcome { + if a == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + return maps.Clone(a.outcomes) +} + +// sayCalls makes a refused call of the controller loud (issue 774): the condition of each pair whose last call was refused +// is raised once per refusal, naming the verb, the node, the module and the controller's words; it is cleared when a later +// call for the pair is answered, or, for a module's place, once the pair differed after the refusal and the two sources now +// agree (a person called the verb by hand). The first pass after a start clears what a previous process raised for a pair +// the two sources agree on; a pin's stays, since nothing here can tell it was made since. A say that fails is made again at +// the next pass. +func (p *passer) sayCalls(differ []assign.Pair) { + if p.say == nil { + return + } + differs := map[string]bool{} + for _, d := range differ { + differs[d.Node+"."+d.Module] = true + } + outcomes := p.seat.outcomesNow() + if p.callSaid == nil { + p.callSaid = map[string]*callSaid{} + if p.findings.Keys != nil { + if keys, err := p.findings.Keys(); err == nil { + for _, k := range keys { + pair, ok := strings.CutSuffix(strings.TrimPrefix(k, "module."), "."+assign.KindControllerCall) + node, module, two := strings.Cut(pair, ".") + if !ok || !two || !strings.HasPrefix(k, "module.") || differs[pair] { + continue + } + c := assign.ControllerCall{Verb: "assign", Node: node, Module: module} + if _, made := outcomes[assign.CallConditionKey(c)]; made { + continue + } + if err := p.say(assign.CallRefusedCondition(c, "", false, "the controller's assignments and the placement agree on "+module+" on "+node+".")); err != nil { + p.logf("the refused call's condition of %s on %s could not be cleared: %v", module, node, err) + } + } + } + } + } + for key, o := range outcomes { + said := p.callSaid[key] + pair := o.call.Node + "." + o.call.Module + switch { + case o.refused != "" && (said == nil || said.refused != o.refused): + if err := p.say(assign.CallRefusedCondition(o.call, o.refused, true, "")); err != nil { + p.logf("that %s was refused could not be said, and is said at the next pass: %v", o.call.Key(), err) + continue + } + p.callSaid[key] = &callSaid{refused: o.refused, differed: differs[pair]} + case o.refused != "" && o.call.CallKind() == assign.KindControllerCall: + if differs[pair] { + said.differed = true + continue + } + if !said.differed { + continue // the pair was not seen differing since the refusal: the world may not hold the write yet + } + if err := p.say(assign.CallRefusedCondition(o.call, "", false, "the controller's assignments and the placement agree on "+o.call.Module+" on "+o.call.Node+".")); err != nil { + p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err) + continue + } + p.seat.forgetOutcome(key, o) + delete(p.callSaid, key) + case o.refused == "" && said != nil: + if err := p.say(assign.CallRefusedCondition(o.call, "", false, o.call.Key()+" was answered.")); err != nil { + p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err) + continue + } + delete(p.callSaid, key) + } + } +} + +// callSaid is the refusal a raised call's condition said, and whether its pair was seen differing since. +type callSaid struct { + refused string + differed bool +} + +// forgetOutcome drops a refused call's outcome once its condition is cleared, unless a later call replaced it. +func (a *seatActs) forgetOutcome(key string, o callOutcome) { + a.mu.Lock() + defer a.mu.Unlock() + if now, ok := a.outcomes[key]; ok && now.refused == o.refused && now.call.Verb == o.call.Verb { + delete(a.outcomes, key) + } +} + // callOf is what the last call for a pair came to, "" when none was made. func (a *seatActs) callOf(node, module string) string { if a == nil { @@ -671,7 +777,7 @@ func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.An p.seat.record(answer.Warrant) p.sayChanged(prop, answer, changes) if p.seat != nil && !p.w.handedAway() && !prop.Seed { - p.seat.callAll(assign.ControllerCalls(before, written, prop.ID, answer.Warrant)) + p.seat.callAll(assign.ControllerCalls(before, written)) } } diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index beac707..b340ced 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go index 05a02c6..7e9b349 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -132,11 +132,14 @@ func (c ControllerCall) Key() string { } // ControllerCalls is every call that brings the controller's assignments and pins to the placement after: assign for a -// module placed on a node before not, unassign for one taken off, pin for a pin set or changed, unpin for one removed, -// each naming the proposal and its warrant in its why. In node order, unassigns before assigns, so a module moved -// between nodes is first taken off. -func ControllerCalls(before map[string]delivery.Placement, after map[string]*delivery.Placement, proposal, warrant string) []ControllerCall { - why := fmt.Sprintf("placement proposal %s, approved (%s)", proposal, warrant) +// module placed on a node before not, unassign for one taken off, pin for a pin set or changed, unpin for one removed. In +// node order, unassigns before assigns, so a module moved between nodes is first taken off. +// +// Each call carries only the arguments the controller's seat contract declares for its verb (catalogue.ControllerVerbs): +// the controller refuses any other argument and does nothing (issue 774: a "why" added to every call had every approved +// placement refused since 2026-10-28). The proposal and its warrant are in the assigner's own record instead: the +// placement-changed event and the hand-act log. +func ControllerCalls(before map[string]delivery.Placement, after map[string]*delivery.Placement) []ControllerCall { nodes := make([]string, 0, len(after)) for node := range after { nodes = append(nodes, node) @@ -176,11 +179,50 @@ func ControllerCalls(before map[string]delivery.Placement, after map[string]*del } } } - out := append(append(off, on...), pins...) - for i := range out { - out[i].Args["why"] = why + return append(append(off, on...), pins...) +} + +// CallKind is the kind of the condition of a refused call: one for a module's place (assign, unassign), one for a pin. +func (c ControllerCall) CallKind() string { + if c.Verb == "pin" || c.Verb == "unpin" { + return KindControllerPin } - return out + return KindControllerCall +} + +// The kinds of a refused controller call's condition (and finding). +const ( + KindControllerCall = "controller-call" + KindControllerPin = "controller-pin" +) + +// CallConditionKey is the key of the condition of a call refused for its pair (node and module, or node and provision). +func CallConditionKey(c ControllerCall) string { + return delivery.AssignerModule + "." + c.CallKind() + "." + c.Node + "." + c.Module +} + +// CallRefusedCondition is the condition the assigner raises when the controller refused (or did not answer) a call that +// keeps its assignments in step with an approved placement (ADR 0567 §8, issue 774): the placement is written, the +// controller's assignments are not, so nothing new reaches the node (no bus account, no composition). It names the verb, +// the node, the module (or provision) and the controller's words, keyed by the pair, and is cleared (raised false) when a +// later call for the pair is answered, or both sources agree on it, with resolved saying which. +func CallRefusedCondition(c ControllerCall, refusal string, raised bool, resolved string) asks.Condition { + key := CallConditionKey(c) + subject := asks.Scope{Scope: "module", ID: c.Module, Machine: c.Node} + if !raised { + return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: resolved} + } + headline := fmt.Sprintf("the controller refused %s %s on %s", c.Verb, c.Module, c.Node) + if n := len([]rune(headline)); n > asks.HeadlineLength { + headline = string([]rune(headline)[:asks.HeadlineLength]) + } + needs := fmt.Sprintf("call mesh-controller.%s for %s on %s by hand once the refusal's cause is fixed", c.Verb, c.Module, c.Node) + return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, + Summary: headline, Headline: headline, Needs: needs, + Explanation: fmt.Sprintf("%s%s. An approved placement was written, and the assigner's call %s to keep the controller's "+ + "assignments in step was refused: %s. Until the controller holds it, the placement and the assignments differ, so "+ + "the assigner writes no want for the pair and nothing new reaches %s. This clears when a call for it is answered "+ + "or both sources agree.", asks.NeedsYou, needs, c.Key(), refusal, c.Node)} } // PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions. diff --git a/modules/mesh-delivery-assigner/internal/assign/seat_test.go b/modules/mesh-delivery-assigner/internal/assign/seat_test.go index 257da34..669d124 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat_test.go @@ -8,6 +8,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -108,13 +109,10 @@ func TestThePlacementSeatDecides(t *testing.T) { pinned := *laid["laptop"] pinned.Pins = map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}} calls := ControllerCalls(map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, - map[string]*delivery.Placement{"anchor": laid["anchor"], "laptop": &pinned}, "p-one", "w1@t") + map[string]*delivery.Placement{"anchor": laid["anchor"], "laptop": &pinned}) var said []string for _, c := range calls { said = append(said, c.Key()) - if !strings.Contains(c.Args["why"].(string), "p-one") { - t.Errorf("%s does not name the proposal: %v", c.Key(), c.Args) - } } if !slices.Equal(said, []string{"mesh-controller.unassign anchor web", "mesh-controller.assign laptop web", "mesh-controller.pin laptop db"}) { t.Errorf("the controller's calls = %v", said) @@ -155,3 +153,86 @@ func quote(s string) string { raw, _ := json.Marshal(s) return string(raw) } + +// Issue 774: every call the assigner makes of the controller carries only the arguments the controller's seat contract +// declares for its verb, and every one it requires; the controller refuses any other argument and does nothing. A "why" +// added to every call (2026-10-28) had each approved placement refused. +func TestControllerCallsFollowTheContract(t *testing.T) { + declared := map[string]map[string]any{} + required := map[string][]string{} + for _, v := range catalogue.ControllerVerbs { + props, _ := v.Input["properties"].(map[string]any) + declared[v.Name] = props + switch r := v.Input["required"].(type) { + case []string: + required[v.Name] = r + } + } + db := map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}} + moved := map[string]delivery.Provider{"db": {Node: "laptop", Module: "store"}} + placed := func(node string, pins map[string]delivery.Provider, modules ...string) delivery.Placement { + p := delivery.Placement{Node: node, Pins: pins} + for _, m := range modules { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + return p + } + ptr := func(p delivery.Placement) *delivery.Placement { return &p } + for name, c := range map[string]struct { + before map[string]delivery.Placement + after map[string]*delivery.Placement + verbs []string + }{ + "assign": {map[string]delivery.Placement{}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil, "web"))}, []string{"assign"}}, + "unassign": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil))}, []string{"unassign"}}, + "key deleted": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": nil}, []string{"unassign", "unpin"}}, + "pin set": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", db, "web"))}, []string{"pin"}}, + "pin changed": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", moved, "web"))}, []string{"pin"}}, + "moved and pinned": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil)), "laptop": ptr(placed("laptop", db, "web"))}, []string{"unassign", "assign", "pin"}}, + } { + calls := ControllerCalls(c.before, c.after) + var verbs []string + for _, call := range calls { + verbs = append(verbs, call.Verb) + props, ok := declared[call.Verb] + if !ok { + t.Errorf("%s: the controller's seat has no verb %q", name, call.Verb) + continue + } + for arg := range call.Args { + if _, ok := props[arg]; !ok { + t.Errorf("%s: %s gives %q, which mesh-controller.%s does not take, so the controller refuses it", name, call.Key(), arg, call.Verb) + } + } + for _, arg := range required[call.Verb] { + if _, ok := call.Args[arg]; !ok { + t.Errorf("%s: %s lacks %q, which mesh-controller.%s requires", name, call.Key(), arg, call.Verb) + } + } + } + if !slices.Equal(verbs, c.verbs) { + t.Errorf("%s: the calls = %v, want %v", name, verbs, c.verbs) + } + } +} + +// Issue 774: a refused call is a condition keyed by its pair, a module's place apart from a pin, naming the verb, the node, the +// module and the refusal; its clear says why. +func TestCallRefusedCondition(t *testing.T) { + assign := ControllerCall{Verb: "assign", Node: "anchor", Module: "web"} + pin := ControllerCall{Verb: "pin", Node: "anchor", Module: "web"} + c := CallRefusedCondition(assign, `assign takes no argument "why"`, true, "") + if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Key != "mesh-delivery-assigner.controller-call.anchor.web" || + !strings.Contains(c.Headline, "assign web on anchor") || !strings.Contains(c.Explanation, `takes no argument "why"`) { + t.Errorf("the raised condition = %+v", c) + } + if CallConditionKey(pin) == c.Key { + t.Error("a pin's condition shares the key of a module's place") + } + if cl := CallRefusedCondition(assign, "", false, "answered"); cl.State != asks.StateCleared || cl.Key != c.Key || cl.Resolved != "answered" { + t.Errorf("the clear = %+v", cl) + } + if _, _, raised, err := FindingOf(c, time.Unix(0, 0)); err != nil || !raised { + t.Errorf("the condition is no finding: %v", err) + } +} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index f958dc7..0c4752d 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 3d9b531..cd76564 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index effc726..6d8375e 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index db3b91a..3e217b5 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index 9d7e87f..3572e89 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index b8680fd..edf4553 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index 6123083..156a748 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index a230c7d..b37d585 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-watchman go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-watchman/go.sum b/modules/mesh-delivery-watchman/go.sum index 97d3112..f59f301 100644 --- a/modules/mesh-delivery-watchman/go.sum +++ b/modules/mesh-delivery-watchman/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index be78eaa..d709dc1 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 991de7e..d88df5e 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 8fe6442c4754dac230e1bc69ddbf2a0f9236cbc8 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 21:27:35 +0100 Subject: [PATCH 2/2] Clear a refused pin's condition once the controller's pins agree, and take over a previous process's conditions only once the findings are read (issue 774) A pin made by hand, as the condition's Needs asks, changed nothing the assigner looked at, so its urgent condition never cleared, and a restart left every one raised. The assigner now reads the controller's pins (placement-export, asked again only after the controller's compositions change, never on a timer) and clears a pin's condition once they agree with the placement's after being seen differing, as a module's place already did. The first pass takes over the call conditions a previous process raised, pins and places alike, and retries at the next pass when the findings cannot be read. --- .../cmd/mesh-delivery-assigner/pass.go | 6 +- .../cmd/mesh-delivery-assigner/pass_test.go | 108 ++++++++++ .../cmd/mesh-delivery-assigner/seat.go | 191 ++++++++++++++---- .../cmd/mesh-delivery-assigner/world.go | 9 + 4 files changed, 268 insertions(+), 46 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 76d1583..a9f304b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -58,9 +58,11 @@ type passer struct { // nodeFailed is, by node and module, the words of the condition last raised for a failure that is the node's own (ADR 0557 // §1, issue 1496): raised once per words, cleared once when it ends. nodeFailed map[string]string - // callSaid is, by the key of a refused controller call's condition, what this process raised (issue 774); nil until the - // first pass after a start, which clears what a previous process raised for a pair the two sources agree on. + // callSaid is, by the key of a refused controller call's condition, what is raised (issue 774): by this process, or by + // a previous one and taken over from the findings. callSaid map[string]*callSaid + // callsSwept says the first pass took over the refused calls' conditions a previous process raised (sweepCalls). + callsSwept bool // The seat mesh-placement's holder (seat.go, ADR 0567): its calls of the controller (nil: none is made), how it says // the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base. seat *seatActs diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index bed2ef1..0cba96b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -7,6 +7,7 @@ import ( "fmt" "slices" "strings" + "sync" "testing" "time" @@ -976,9 +977,28 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { type refusingBus struct { fakeBus refuse map[string]string + // pins are the controller's pins its placement-export answers, by node. + mu sync.Mutex + pins map[string]map[string]delivery.Provider +} + +func (b *refusingBus) setPins(node string, pins map[string]delivery.Provider) { + b.mu.Lock() + defer b.mu.Unlock() + if b.pins == nil { + b.pins = map[string]map[string]delivery.Provider{} + } + b.pins[node] = pins } func (b *refusingBus) Call(key string, body any) (json.RawMessage, error) { + if key == exportCall { + b.mu.Lock() + defer b.mu.Unlock() + node, _ := body.(map[string]any)["node"].(string) + answer, _ := json.Marshal(map[string]delivery.Placement{node: {V: delivery.Version, Node: node, Pins: b.pins[node]}}) + return json.Marshal(map[string]any{"ok": true, "answer": json.RawMessage(answer)}) + } for verb, words := range b.refuse { if strings.HasSuffix(key, "."+verb) { return nil, errors.New(words) @@ -1040,3 +1060,91 @@ func TestARefusedControllerCallIsACondition(t *testing.T) { t.Fatalf("said again after the clear: %+v", conds) } } + +// Issue 774: a refused pin's condition, and one a previous process raised, clear once the pair agrees after it was seen differing: +// the controller's pins (its placement-export, read again when its compositions change) against the placement's, the controller's +// assignments against the placement for a module's place. A start whose findings cannot be read takes them over at a later pass. +func TestARefusedCallClearsWhenTheSourcesAgree(t *testing.T) { + store := delivery.Provider{Node: "anchor", Module: "store"} + pinDB := assign.ControllerCall{Verb: "pin", Node: "laptop", Module: "db", + Args: map[string]any{"node": "laptop", "provision": "db", "from": "anchor", "module": "store"}} + placeWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}} + webDiffers := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}} + for name, c := range map[string]struct { + call *assign.ControllerCall // refused in this process; nil: raised by a previous one + findings []string // the findings a previous process left + keysFail int // how many first reads of the findings fail + differ []assign.Pair // the pairs the two sources name apart, while they differ + agree func(*refusingBus) // the person's hand call + key string + }{ + "a pin refused, pinned by hand": {call: &pinDB, agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) }, + key: "mesh-delivery-assigner.controller-pin.laptop.db"}, + "a place refused, assigned by hand": {call: &placeWeb, differ: webDiffers, agree: func(*refusingBus) {}, + key: "mesh-delivery-assigner.controller-call.anchor.web"}, + "a pin raised before a start": {findings: []string{"module.laptop.db.controller-pin"}, keysFail: 1, + agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) }, + key: "mesh-delivery-assigner.controller-pin.laptop.db"}, + "a place raised before a start": {findings: []string{"module.anchor.web.controller-call"}, differ: webDiffers, agree: func(*refusingBus) {}, + key: "mesh-delivery-assigner.controller-call.anchor.web"}, + } { + t.Run(name, func(t *testing.T) { + p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC)) + var conds []asks.Condition + p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } + fails := c.keysFail + p.findings.Keys = func() ([]string, error) { + if fails > 0 { + fails-- + return nil, errors.New("the bucket is not there yet") + } + return c.findings, nil + } + bus := &refusingBus{refuse: map[string]string{"pin": "refused", "assign": "refused"}} + woke := make(chan struct{}, 16) + p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false }) + // The placement pins db on laptop; the controller does not. + raw, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "laptop", Pins: map[string]delivery.Provider{"db": store}}) + key, _ := delivery.PlacementKey("laptop") + p.w.put(delivery.PlacementBucket, key, raw) + // pass is a pass, then one more for each background read it started. + pass := func(differ []assign.Pair) { + p.sayCalls(differ) + for { + select { + case <-woke: + p.sayCalls(differ) + case <-time.After(20 * time.Millisecond): + return + } + } + } + if c.call != nil { + p.seat.callAll([]assign.ControllerCall{*c.call}) + <-woke + } + pass(c.differ) + pass(c.differ) + for _, cond := range conds { + if cond.State == asks.StateCleared { + t.Fatalf("cleared while the sources differ: %+v", conds) + } + } + c.agree(bus) + p.w.put(delivery.ControllerComposedBucket, "laptop.x", []byte("moved")) // a pin changes a composition + pass(nil) + if len(conds) == 0 { + t.Fatal("nothing was raised") + } + last := conds[len(conds)-1] + if last.Key != c.key || last.State != asks.StateCleared || !strings.Contains(last.Resolved, "agree") { + t.Fatalf("the hand call does not clear it: %+v", conds) + } + n := len(conds) + pass(nil) + if len(conds) != n { + t.Errorf("said again after the clear: %+v", conds[n:]) + } + }) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index 7de5f98..02c4adf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -71,6 +71,8 @@ type seatActs struct { // outcomes is, by the key of a call's condition, the last call made for its pair once answered or refused: refused // "" when the controller answered it (issue 774: a refused call is a condition, never only a journal line). outcomes map[string]callOutcome + // pins is, by node, the controller's pins as its placement-export last answered them (pinsOf). + pins map[string]pinRead // rec records the seat's acts in the hand-act log (ADR 0565 §11). rec handacts.Recorder } @@ -90,7 +92,7 @@ type exportCheck struct { func newSeatActs(bus caller, wake func(), logf func(string, ...any), leaseAway func() bool) *seatActs { return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, calls: map[string]string{}, - outcomes: map[string]callOutcome{}, rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}} + outcomes: map[string]callOutcome{}, pins: map[string]pinRead{}, rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}} } // exportOf is the export read again for a proposal, asked once in the background. @@ -193,66 +195,86 @@ func (a *seatActs) outcomesNow() map[string]callOutcome { return maps.Clone(a.outcomes) } +// pinRead is the controller's pins of one node as its placement-export answered them, for the generation of the +// controller's compositions it was asked at: a pin made at the controller changes a composition, which the assigner +// watches, so a read is asked again only after such a change, never on a timer. +type pinRead struct { + gen uint64 + running bool + known bool + pins map[string]delivery.Provider +} + +// pinsOf is the controller's pins of node as last read, and whether a read answered; a read is asked in the background +// when none was made at gen, and wakes a pass when it answers. +func (a *seatActs) pinsOf(node string, gen uint64) (map[string]delivery.Provider, bool) { + if a == nil { + return nil, false + } + a.mu.Lock() + defer a.mu.Unlock() + r, ok := a.pins[node] + if !ok || (r.gen != gen && !r.running) { + r.gen, r.running = gen, true + a.pins[node] = r + go func() { + defer a.wake() + raw, err := a.bus.Call(exportCall, map[string]any{"node": node}) + var pins map[string]delivery.Provider + var known bool + if err == nil { + var export map[string]delivery.Placement + if export, err = assign.ExportOf(raw); err == nil { + pins, known = export[node].Pins, true + } + } + if err != nil { + a.logf("the controller's pins of %s were not read, so a refused pin's condition there waits: %v", node, err) + } + a.mu.Lock() + now := a.pins[node] + now.running = false + if known { + now.pins, now.known = pins, true + } + a.pins[node] = now + a.mu.Unlock() + }() + } + return r.pins, r.known +} + // sayCalls makes a refused call of the controller loud (issue 774): the condition of each pair whose last call was refused -// is raised once per refusal, naming the verb, the node, the module and the controller's words; it is cleared when a later -// call for the pair is answered, or, for a module's place, once the pair differed after the refusal and the two sources now -// agree (a person called the verb by hand). The first pass after a start clears what a previous process raised for a pair -// the two sources agree on; a pin's stays, since nothing here can tell it was made since. A say that fails is made again at -// the next pass. +// is raised once per refusal, naming the verb, the node, the module and the controller's words. It is cleared when a later +// call for the pair is answered, or once the pair, seen differing since the refusal, agrees between the two sources (a +// person made the call by hand): for a module's place the controller's assignments and the placement, for a pin the +// controller's pins (its placement-export, read again after its compositions change) and the placement's. The first pass +// after a start takes over what a previous process raised (its findings) and clears each whose pair agrees; until the +// findings are read it is tried again at every pass. A say that fails is made again at the next pass. func (p *passer) sayCalls(differ []assign.Pair) { if p.say == nil { return } + if p.callSaid == nil { + p.callSaid = map[string]*callSaid{} + } differs := map[string]bool{} for _, d := range differ { differs[d.Node+"."+d.Module] = true } outcomes := p.seat.outcomesNow() - if p.callSaid == nil { - p.callSaid = map[string]*callSaid{} - if p.findings.Keys != nil { - if keys, err := p.findings.Keys(); err == nil { - for _, k := range keys { - pair, ok := strings.CutSuffix(strings.TrimPrefix(k, "module."), "."+assign.KindControllerCall) - node, module, two := strings.Cut(pair, ".") - if !ok || !two || !strings.HasPrefix(k, "module.") || differs[pair] { - continue - } - c := assign.ControllerCall{Verb: "assign", Node: node, Module: module} - if _, made := outcomes[assign.CallConditionKey(c)]; made { - continue - } - if err := p.say(assign.CallRefusedCondition(c, "", false, "the controller's assignments and the placement agree on "+module+" on "+node+".")); err != nil { - p.logf("the refused call's condition of %s on %s could not be cleared: %v", module, node, err) - } - } - } - } + if !p.callsSwept { + p.callsSwept = p.sweepCalls(outcomes) } for key, o := range outcomes { said := p.callSaid[key] - pair := o.call.Node + "." + o.call.Module switch { - case o.refused != "" && (said == nil || said.refused != o.refused): + case o.refused != "" && (said == nil || said.adopted || said.refused != o.refused): if err := p.say(assign.CallRefusedCondition(o.call, o.refused, true, "")); err != nil { p.logf("that %s was refused could not be said, and is said at the next pass: %v", o.call.Key(), err) continue } - p.callSaid[key] = &callSaid{refused: o.refused, differed: differs[pair]} - case o.refused != "" && o.call.CallKind() == assign.KindControllerCall: - if differs[pair] { - said.differed = true - continue - } - if !said.differed { - continue // the pair was not seen differing since the refusal: the world may not hold the write yet - } - if err := p.say(assign.CallRefusedCondition(o.call, "", false, "the controller's assignments and the placement agree on "+o.call.Module+" on "+o.call.Node+".")); err != nil { - p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err) - continue - } - p.seat.forgetOutcome(key, o) - delete(p.callSaid, key) + p.callSaid[key] = &callSaid{call: o.call, refused: o.refused} case o.refused == "" && said != nil: if err := p.say(assign.CallRefusedCondition(o.call, "", false, o.call.Key()+" was answered.")); err != nil { p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err) @@ -261,12 +283,93 @@ func (p *passer) sayCalls(differ []assign.Pair) { delete(p.callSaid, key) } } + var placements map[string]delivery.Placement + for key, said := range p.callSaid { + c := said.call + var apart bool + if c.CallKind() == assign.KindControllerCall { + apart = differs[c.Node+"."+c.Module] + } else { + theirs, known := p.seat.pinsOf(c.Node, p.w.bucketGen(delivery.ControllerComposedBucket)) + if !known { + continue + } + if placements == nil { + placements, _ = p.seatPlacements() + } + ours, has := placements[c.Node].Pins[c.Module] + to, had := theirs[c.Module] + apart = has != had || ours != to + } + if apart { + said.differed = true + continue + } + if !said.differed { + continue // not seen differing since the refusal: the world may not hold the write yet + } + if err := p.say(assign.CallRefusedCondition(c, "", false, agreeWords(c))); err != nil { + p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", c.Key(), err) + continue + } + if o, ok := outcomes[key]; ok { + p.seat.forgetOutcome(key, o) + } + delete(p.callSaid, key) + } } -// callSaid is the refusal a raised call's condition said, and whether its pair was seen differing since. +// sweepCalls takes over the refused calls' conditions a previous process raised, from the findings: each is followed as +// one seen differing, so it clears at the first pass its pair agrees. It answers whether the findings were read. +func (p *passer) sweepCalls(outcomes map[string]callOutcome) bool { + if p.findings.Keys == nil { + return true + } + keys, err := p.findings.Keys() + if err != nil { + p.logf("the findings were not read, so the refused calls a previous process raised are taken over at the next pass: %v", err) + return false + } + for _, k := range keys { + rest, ok := strings.CutPrefix(k, "module.") + if !ok { + continue + } + for _, kind := range []string{assign.KindControllerCall, assign.KindControllerPin} { + pair, ok := strings.CutSuffix(rest, "."+kind) + node, module, two := strings.Cut(pair, ".") + if !ok || !two { + continue + } + verb := "assign" + if kind == assign.KindControllerPin { + verb = "pin" + } + c := assign.ControllerCall{Verb: verb, Node: node, Module: module} + key := assign.CallConditionKey(c) + if _, made := outcomes[key]; made || p.callSaid[key] != nil { + continue + } + p.callSaid[key] = &callSaid{call: c, differed: true, adopted: true} + } + } + return true +} + +func agreeWords(c assign.ControllerCall) string { + if c.CallKind() == assign.KindControllerPin { + return "the controller's pins and the placement agree on " + c.Module + " for " + c.Node + "." + } + return "the controller's assignments and the placement agree on " + c.Module + " on " + c.Node + "." +} + +// callSaid is a raised call's condition: its call, the refusal it said, whether its pair was seen differing since, and +// whether it was taken over from a previous process. type callSaid struct { + call assign.ControllerCall refused string differed bool + adopted bool } // forgetOutcome drops a refused call's outcome once its condition is cleared, unless a later call replaced it. diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 1acdc2b..c848c28 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -100,6 +100,7 @@ func (w *world) put(bucket, key string, value []byte) { if w.values[bucket] == nil { w.values[bucket] = map[string][]byte{} } + w.changes[bucket]++ // the bucket's generation (bucketGen), a delete included if value == nil { delete(w.values[bucket], key) return @@ -662,6 +663,14 @@ func (w *world) answer(key string) (delivery.Answer, bool) { return a, err == nil } +// bucketGen is how many times any key of the bucket was written or deleted in this process: a read of what it shows +// elsewhere is asked again only after it moves. +func (w *world) bucketGen(bucket string) uint64 { + w.mu.Lock() + defer w.mu.Unlock() + return w.changes[bucket] +} + // changed is how many values the key has taken in this process (see world.changes). func (w *world) changed(bucket, key string) uint64 { w.mu.Lock() -- 2.54.0