Node-engine: a held own composition that was handed stays handed, as a held module's does (ADR 0636 §1; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 16:28:02 +01:00
parent b0baa17836
commit e282e08917
2 changed files with 66 additions and 2 deletions
+5 -2
View File
@@ -1029,8 +1029,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev})
}
for key := range c.handed {
if _, module, _, _, err := engcomp.ParseKey(key); err == nil && module != "" {
if _, isHeld := held[module]; isHeld {
if _, module, _, isNode, err := engcomp.ParseKey(key); err == nil {
if isNode {
module = agent.NodeModule // the node's own composition is held under its scope
}
if _, isHeld := held[module]; isHeld && module != "" {
continue // held: what was handed of it stays, so the node keeps running it
}
}
+61
View File
@@ -20,6 +20,7 @@ import (
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
"github.com/novox/mesh-host/internal/compositions"
"maps"
"os"
"reflect"
"slices"
@@ -1851,6 +1852,66 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) {
}
}
func TestAHeldOwnCompositionThatWasHandedIsNotDropped(t *testing.T) {
b := newFakeBus()
fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
digest := meshcatalogclient.ManifestDigest([]byte(lemurs))
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs})
key, _ := meshcatalogclient.ManifestsKey(digest)
b.kv[buckets.Manifests+"/"+key] = man
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
env := b.env()
g := &gate{}
changed := make(chan struct{}, 1)
env.SeatList, env.SeatListChanged = g, changed
passes := make(chan map[string]string, 8)
env.Refused = func(_ string, held map[string]string) { passes <- maps.Clone(held) }
c := New(env)
out, err := c.Watch(ctx, "n1.>")
if err != nil {
t.Fatal(err)
}
place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}})
have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true})
nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true})
b.put(t, buckets.Placement, "placement.n1", place)
b.put(t, buckets.Have, "n1.node", have)
b.put(t, buckets.Modules, "lemurs", mod)
b.put(t, buckets.Wants, "want.n1.lemurs", want)
b.put(t, buckets.Wants, "want.n1.node", nodeWant)
for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} {
b.send(bucket, agent.Update{Caught: true})
}
// The list is read: the node's own composition is handed. Then the reader cannot act again (it restarted and has
// not read): the composition is held, and what was handed of it stays, as a held module's does.
g.open.Store(true)
changed <- struct{}{}
for u := next(t, out); !(u.Key == "n1.node" && !u.Deleted); u = next(t, out) {
}
g.open.Store(false)
changed <- struct{}{}
for h := range passes {
if h[agent.NodeModule] == WaitsForTheSeatListOwn {
break
}
}
deadline := time.After(100 * time.Millisecond)
for {
select {
case u := <-out:
if u.Key == "n1.node" && u.Deleted {
t.Fatalf("the held own composition that was handed is deleted, and the node drops what it runs")
}
case <-deadline:
return
}
}
}
// ADR 0636 §1, the cycle of the node's own composition: while the list is unread the node's own composition is held, but
// a node whose new own composition must start the holder of the seat mesh-catalog composes of it, whatever the list says,
// the holder's resources and its account and nothing else, over what the node's own composition ran before; and the rest