From 4179be7a56658bd3aa07d6ee222b41b0de04c146 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:09:22 +0100 Subject: [PATCH] C-1: a node's holdings are said as holdings-changed, and again when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The licence manager follows the nodes' holdings by an event and asks for them with holdings.asked, instead of reading the agent module's state (ADR 0596 §2). The state holdings stays the module's own. Builds: design 39 bus-and-switch §23 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-code/config_test.go | 2 +- .../claude-code/cmd/claude-code/holdings.go | 37 +++++++++++++ .../cmd/claude-code/holdings_test.go | 52 +++++++++++++++++++ modules/claude-code/cmd/claude-code/main.go | 35 ++++++++++--- modules/claude-code/module.json | 6 ++- 5 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 modules/claude-code/cmd/claude-code/holdings.go create mode 100644 modules/claude-code/cmd/claude-code/holdings_test.go diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 5cdbc2238..bb985c3b7 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -279,7 +279,7 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) || !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) { + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/holdings.go b/modules/claude-code/cmd/claude-code/holdings.go new file mode 100644 index 000000000..38d036408 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings.go @@ -0,0 +1,37 @@ +package main + +// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as +// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again +// when the licence manager says `holdings.asked`. + +import "encoding/json" + +// HoldingsChanged is the event that says what a node holds. +const HoldingsChanged = "holdings-changed" + +// HoldingsAsked is the licence manager's event asking every node to say what it holds. +const HoldingsAsked = Manager + ".holdings.asked" + +// HoldingsSayer keeps and says what this node holds. +type HoldingsSayer struct { + Put func(Holdings) error + Emit func(Holdings) error + said string +} + +// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A +// failure says nothing was said, so the next call tries again. +func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) { + raw, _ := json.Marshal(now) + if !again && string(raw) == s.said { + return false, nil + } + if err := s.Put(now); err != nil { + return false, err + } + if err := s.Emit(now); err != nil { + return false, err + } + s.said = string(raw) + return true, nil +} diff --git a/modules/claude-code/cmd/claude-code/holdings_test.go b/modules/claude-code/cmd/claude-code/holdings_test.go new file mode 100644 index 000000000..c74b0d682 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the +// module's own state each time, the report whole and never a token. +func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) { + var kept, said []string + failing := false + s := &HoldingsSayer{ + Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil }, + Emit: func(h Holdings) error { + if failing { + return errors.New("no responders") + } + raw, _ := json.Marshal(h) + said = append(said, string(raw)) + return nil + }, + } + fp := "sha256:0123456789abcdef" + start := Holdings{Node: "laptop"} + changed := Holdings{Node: "laptop"} + changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp + for i, step := range []struct { + h Holdings + again, out bool + }{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} { + if got, err := s.Say(step.h, step.again); err != nil || got != step.out { + t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out) + } + } + if len(said) != 3 || len(kept) != 3 { + t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept)) + } + if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) { + t.Fatalf("the report is not said whole: %s", said[1]) + } + failing = true + if got, err := s.Say(start, false); err == nil || got { + t.Fatal("a failed event counts as said") + } + failing = false + if got, _ := s.Say(start, false); !got { + t.Fatal("what failed to be said is not said at the next call") + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 53a18a1a0..6c7580ebf 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -20,6 +20,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" @@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) { // every change of the credentials file, polled, because the file is replaced by rename and a watch on // the old inode would go quiet. Fingerprints and expiries only. holdings := stdio.State("holdings") - reported := "" - report := func() { + var sayMu sync.Mutex + sayer := &HoldingsSayer{ + Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err }, + Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) }, + } + sayHoldings := func(again bool) { + sayMu.Lock() + defer sayMu.Unlock() now := HoldingsOf(p) - raw, _ := json.Marshal(now) - if string(raw) == reported { - return - } - persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) { - reported = string(raw) + said := false + persist("reporting what this node holds", func() error { + var err error + said, err = sayer.Say(now, again) + return err + }, func(int) { + if !said { + return + } account := "no account" if now.Identity != nil && now.Identity.EmailAddress != "" { account = now.Identity.EmailAddress @@ -593,6 +603,15 @@ func run(p Paths, view *ServerView, configView *ConfigView) { say("%s", line) }) } + report := func() { sayHoldings(false) } + // The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on + // its resync. + go persist("hearing the licence manager ask what this node holds", func() error { + return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error { + go sayHoldings(true) + return nil + }) + }, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) }) // What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer // generation is fetched with the seat's `current`, sealed to this module's key. diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 7bfbd71fd..c3e46ff70 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -35,7 +35,8 @@ "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", - "mesh-catalog.seat-definition-removed" + "mesh-catalog.seat-definition-removed", + "claude-licence-manager.holdings.asked" ], "state": [ "servers", @@ -48,7 +49,8 @@ "findings" ], "emits": [ - "judged" + "judged", + "holdings-changed" ], "reads": [ "claude-licence-manager.bindings"