diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 6cf6335..d22910f 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -659,6 +659,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus machinesNow.Store(composer) // the machines a failing network part points at, by the assignments (issue 774) composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday + env.WhyUnmade, env.Waiting = composer.WhyUnmade, composer.Waiting env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere") ag = agent.New(env) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d92bed2..4577e49 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -125,6 +125,11 @@ type Env struct { Wants Watcher Composed Watcher Haves Haves + // WhyUnmade is why the node's composer composed nothing of a module at its want's fingerprint, from its last pass; empty + // when it says nothing. Nil says nothing. Waiting tells the composer the agent waits for the composition of a module at + // a fingerprint it holds none of, so a composer that believes it handed it hands it again. Nil tells nobody. + WhyUnmade func(module string) string + Waiting func(module, fp string) // Apply applies one module's composition (scope NodeModule for the node's own), given the compositions the node's // other modules run so a path, unit or group they hold is not removed or taken. It is called with one module at a // time. @@ -241,6 +246,8 @@ type Agent struct { // prior is a module the node ran before this agent started, so it has something to back up. prior map[string]bool said map[string]string + // waited is, by module, the want (fingerprint and revision) the agent said it waits for the composer for. + waited map[string]string // notComposed is the modules whose have says the node could not compose them: why, and the have before (NotComposed). notComposed map[string]notComposedMark // heldAlone is the modules the composer holds alone (NotComposedEach): what runs of each in the node's own @@ -306,7 +313,7 @@ func New(env Env) *Agent { env.Say = func(string) {} } return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]nodeengine.HaveByNodeModuleValue{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{}, - attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{}, + attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, waited: map[string]string{}, notComposed: map[string]notComposedMark{}, pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{}, retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64), transient: make(chan struct{}, 1), stopped: make(chan struct{}), quiet: make(chan chan struct{})} @@ -1319,6 +1326,31 @@ func (a *Agent) HoldsNode() bool { return ok && h.Phase != nodeengineclient.PhaseRemoved } +// waitsForComposer says once per want and reason that the agent waits for the node's own composition of module at fp, and +// why the composer made none (Env.WhyUnmade), and tells the composer (Env.Waiting) so it hands again what it believes it +// handed. Called without a.mu held. +func (a *Agent) waitsForComposer(module, fp string, rev uint64) { + why := "the composer has made none yet" + if a.env.WhyUnmade != nil { + if w := a.env.WhyUnmade(module); w != "" { + why = w + } + } + line := fmt.Sprintf("%s waits for the composer: no composition of it at %s: %s", module, fp, why) + // Said and told once per want (its fingerprint and revision), whatever the reason says; forgotten once composed. + at := fmt.Sprintf("%s@%d", fp, rev) + a.mu.Lock() + first := a.waited[module] != at + if first { + a.waited[module] = at + a.env.Say(line) + } + a.mu.Unlock() + if first && a.env.Waiting != nil { + a.env.Waiting(module, fp) + } +} + func (a *Agent) settleOne(ctx context.Context, module string) { a.mu.Lock() w := a.wants[module] @@ -1334,7 +1366,15 @@ func (a *Agent) settleOne(ctx context.Context, module string) { comp, have := a.compositionFor(module, w.Want.Fp) a.mu.Unlock() if a.env.SelfComposed { + if have { + a.mu.Lock() + delete(a.waited, module) + a.mu.Unlock() + } if !have || comp.Err != nil { + if !have { + a.waitsForComposer(module, w.Want.Fp, w.Rev) + } return // waits for the node's own composition of the want's build } // The composition the node composed for the want's build is the one it applies (ADR 0547 §2). diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 2b778d6..082a3fe 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1556,6 +1556,11 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)}) } b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} } + // Issue 1966 (novox, 2026-11-09): a want the composer made no composition of is said once, with why, and the composer told. + var toldMu sync.Mutex + var told []string + b.env.WhyUnmade = func(string) string { return "no manifest in the composer's picture" } + b.env.Waiting = func(module, fp string) { toldMu.Lock(); told = append(told, module+"@"+fp); toldMu.Unlock() } stop := b.run() defer stop() unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) { @@ -1583,6 +1588,41 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( b.comps.put(ck, craw) want("web", fp, "") eventually(t, "the node's own composition was not applied", func() bool { return len(b.appliedList()) == 1 }) + gk := meshwants.WantsByWantNodeModuleKey(node, "ghost") + graw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpOf("9"), Self: true}) + b.wants.put(gk, graw) + line := "ghost waits for the composer: no composition of it at " + fpOf("9") + ": no manifest in the composer's picture" + saidCount := func() (n int) { + b.mu.Lock() + defer b.mu.Unlock() + for _, l := range b.said { + if l == line { + n++ + } + } + return n + } + eventually(t, "the wait for the composer was not said", func() bool { return saidCount() == 1 }) + // Settled again by another want's change, it is neither said nor told again. + other, oraw, _ := unsigned("ghost", fpOf("8"), file("ghost", "x", "1")) + b.comps.put(other, oraw) + eventually(t, "ghost's other composition was not taken", func() bool { + b.ag.mu.Lock() + defer b.ag.mu.Unlock() + _, ok := b.ag.comps[other] + return ok + }) + // What is settled after it: web's want moves on, so ghost's settle, queued before it, has run. + want("web", fp, "") + eventually(t, "the composer was not told once", func() bool { toldMu.Lock(); defer toldMu.Unlock(); return slices.Contains(told, "ghost@"+fpOf("9")) }) + if _, err := b.ag.Composition(context.Background(), "ghost", "", false); err == nil || !strings.Contains(err.Error(), "waits for the composer: no manifest in the composer's picture") { + t.Errorf("the composition read of ghost says %v, want why the composer made none", err) + } + toldMu.Lock() + if n := slices.Index(told, "ghost@"+fpOf("9")); saidCount() != 1 || n < 0 || slices.Contains(told[n+1:], "ghost@"+fpOf("9")) { + t.Errorf("said %d times, told %q; want once and ghost told once", saidCount(), told) + } + toldMu.Unlock() eventually(t, "the have does not name the composed digest", func() bool { return b.haves.last("web").Composed == dig }) // ADR 0601 §4: what the composer keeps of web as it runs is carried on its have, and goes once it keeps none. b.ag.KeptNow(context.Background(), map[string][]string{"web": {"web runs the tool runner's process here"}}) diff --git a/internal/agent/composition.go b/internal/agent/composition.go index d626fb9..37c89d9 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -209,6 +209,11 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo case digest != "" && read == nil: return view, fmt.Errorf("no revision of %s the bucket keeps has the digest %s; revisions lists those it keeps", key, digest) case read == nil && !haveLatest: + if a.env.SelfComposed && a.env.WhyUnmade != nil && module != NodeModule { + if why := a.env.WhyUnmade(module); why != "" { + return view, fmt.Errorf("the bucket holds no composition of %s at %s; the agent waits for the composer: %s", module, fp, why) + } + } return view, fmt.Errorf("the bucket holds no composition of %s at %s", module, fp) case read == nil: read = &latest diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 58bda5b..069e370 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -167,6 +167,10 @@ type Scope struct { // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool // runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere. Kept func(module, words string) + // Unmade is told, in words, each module placed on the node that is composed from the indexer's manifest and not from a + // build, with why: the indexer's modules hold no build of the new form for it, or the builder holds none done at that + // fingerprint. Nil: said nowhere. + Unmade func(module, why string) // takeInHeld, set for the input a node answers a check with (ComposingInput), takes in the build of each module placed // on the node that its manifest's verdict or its decoder holds, into compose.Input.Holds, never into what the node composes: the // check's composer, judging the change's manifest itself, composes it at that build (issue 1921). @@ -930,6 +934,9 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } for _, pm := range p.Modules { m, ok := modules[pm.Module] + if (!ok || m.Fp2 == "") && s.Unmade != nil && wanted[pm.Module] != "" { + s.Unmade(pm.Module, "the indexer's modules hold no build of the new form for it (fingerprint "+m.Fp2+"), so it is composed from the manifest") + } if !ok || m.Fp2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { // A module it runs inside its own composition is composed there as before while it is folded: no build of the // new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds, @@ -975,6 +982,13 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ "keeps running what it runs", pm.Module, m.Fp2)) } + if s.Unmade != nil && wanted[pm.Module] != "" { + state := "none" + if b != nil { + state = string(b.State) + } + s.Unmade(pm.Module, "the builder holds no done build of it at "+m.Fp2+" (build: "+state+"), so it is composed from the indexer's manifest, which names no artifacts") + } continue // nothing of it runs: composed from the indexer's manifest, as before } resolved, err := built(pm.Module, m.Fp2, b) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 989c5ac..d07bdfa 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -154,6 +154,14 @@ type Composer struct { // the data items after it (publishData) do not read each placed module's build again over the bus (ADR 0577). builds map[string]builds.BuilderBuild handed map[string]string // composed key to the digest handed + // notTaken is, by module, why a want of this node's was not taken (it could not be decoded, or names no fingerprint). + notTaken map[string]string + // unmadeSaid is, by module, the stable part of the reason its last journal line said (without where the picture stands). + unmadeSaid map[string]string + // modulesRev is the revision of the last update of the indexer's modules taken: where the picture of them stands. + modulesRev uint64 + // rehand asks for a pass that hands again what the agent waits for (Waiting). + rehand chan struct{} // sealedOwn is each own secret's sealed value as first handed, by module/name and generation: a value sealed again // is other bytes, so the one first had is kept while the generation stands, and the composition stays the same. sealedOwn map[string]ownSealed @@ -238,6 +246,11 @@ type Pass struct { // holds is what holds each module Held names by its manifest's verdict or its decoder, with the build it would be // composed at (frombus.Scope.Holds), for LastHeld's words. holds map[string]compose.Hold + // Unmade is each module wanted that the pass composed no composition of at its want's fp, with why: the want not + // taken, the module not assigned here, no manifest in the composer's picture of the indexer's modules (and where that + // picture stands), or what the composition left it out for. A module held alone is in Held, not here. Said once per + // change as a journal line, so a want that waits for the composer says what for. + Unmade map[string]string `json:"unmade,omitempty"` // Handed is how many compositions the pass handed the agent that differ from what it handed before. Handed int `json:"handed"` // Again is when the node composes again on its own, after a seat or a read that did not answer; zero when it does @@ -340,7 +353,7 @@ func New(env Env) *Composer { } return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, assignments: map[string]meshmoduleassigner.Assignments{}, assignmentKeys: meshmoduleassignerclient.NewKeys(), records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]meshcatalog.ModulesByModuleValue{}, values: map[string]map[string]any{}, - manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, + manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, notTaken: map[string]string{}, unmadeSaid: map[string]string{}, rehand: make(chan struct{}, 1), sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} } @@ -544,6 +557,8 @@ func (c *Composer) Watch(ctx context.Context, _ string) (<-chan agent.Update, er again = nil dirty = true c.env.Say(c.againLine()) + case <-c.rehand: + dirty = true case reply := <-c.asked: // Asked by hand (the verb compose): composed now; the growing bound is not restarted, so a pass that // fails again waits longer, not less. @@ -699,6 +714,13 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) { } func (c *Composer) takeWant(u agent.Update) { + if line := c.takeWantLocked(u); line != "" { + c.sayOnceEach(line) + } +} + +// takeWantLocked takes the want in and answers a line to say when it is a want of this node's that is not taken. +func (c *Composer) takeWantLocked(u agent.Update) string { c.mu.Lock() defer c.mu.Unlock() if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok { @@ -707,18 +729,30 @@ func (c *Composer) takeWant(u agent.Update) { } else { c.wants[agent.NodeModule] = meshwants.WantsByWantNodeModuleValue{} } - return + return "" } node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key) if !ok || node != c.env.Node { - return + return "" } + delete(c.notTaken, module) w, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value) if u.Deleted || err != nil || w.Removed || w.Fp == "" { delete(c.wants, module) - return + switch { + case u.Deleted || (err == nil && w.Removed): + case err != nil: + c.notTaken[module] = "its want cannot be decoded: " + err.Error() + default: + c.notTaken[module] = "its want names no fingerprint" + } + if why := c.notTaken[module]; why != "" { + return "the node composes nothing of " + module + ": " + why + } + return "" } c.wants[module] = w + return "" } func (c *Composer) takeAssignments(u agent.Update) { @@ -844,6 +878,7 @@ func (c *Composer) takeModule(u agent.Update) { return } c.modules[name] = m + c.modulesRev = u.Revision } func (c *Composer) takeValue(u agent.Update) { @@ -946,6 +981,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { keptRuns := map[string][]string{} var keptMu sync.Mutex var holds map[string]compose.Hold + fromManifest := map[string]string{} // by module, why it is composed from the indexer's manifest and not a build scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) { keptMu.Lock() defer keptMu.Unlock() @@ -953,12 +989,16 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { keptRuns[module] = append(keptRuns[module], words) } c.sayOnceEach(words) - }, Holds: func(h map[string]compose.Hold) { holds = h }} + }, Holds: func(h map[string]compose.Hold) { holds = h }, Unmade: func(module, why string) { + keptMu.Lock() + defer keptMu.Unlock() + fromManifest[module] = why + }} c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() missed := c.missed.Load() - compositions, _, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) + compositions, skipped, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) err = c.missedIn(missed, err) // A bus that does not answer for a moment (a reload of its accounts pauses it a few seconds) is no refusal: the pass is // tried again after a growing wait before anything is marked (issue 1126). @@ -970,7 +1010,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } if ctx.Err() == nil { missed = c.missed.Load() - compositions, _, held, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps) + compositions, skipped, held, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps) err = c.missedIn(missed, err) } } @@ -1013,6 +1053,9 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } c.paid() pass.Held, pass.holds = held, holds + var unmadeKeys map[string]string + pass.Unmade, unmadeKeys = c.unmadeOf(fps, compositions, held, skipped, own, inOwn, fromManifest, &keptMu) + c.sayUnmade(pass.Unmade, unmadeKeys) // The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused // under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace, // 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner). @@ -2028,3 +2071,133 @@ func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []strin } return seats } + +// unmadeOf is each module wanted at a fingerprint (fps) of which the pass made no composition at it, with why, apart from +// the modules held alone (Pass.Unmade), and each want of this node's that was not taken. +func (c *Composer) unmadeOf(fps map[string]string, comps map[string]engcomp.Composed, held map[string]string, skipped []string, + own meshmoduleassigner.Assignments, inOwn []string, fromManifest map[string]string, mu *sync.Mutex) (map[string]string, map[string]string) { + made := map[string]string{} + for _, comp := range comps { + if comp.Module != "" { + made[comp.Module] = comp.FP + } + } + placed := map[string]bool{} + for _, m := range own.Modules { + placed[m.Module] = true + } + mu.Lock() + defer mu.Unlock() + c.mu.Lock() + defer c.mu.Unlock() + out, keys := map[string]string{}, map[string]string{} + for module, fp := range fps { + if _, isHeld := held[module]; isHeld { + continue + } + if made[module] == fp { + // Composed, but from the indexer's manifest and not a build: said, since what it names no artifacts of is no module + // that runs. + if w, ok := fromManifest[module]; ok { + out[module], keys[module] = w, fp+" "+w + } + continue + } + var why, key string + switch m, in := c.modules[module]; { + case compose.Folds(slices.Contains(inOwn, module), fp): + why = "it is composed inside the node's own composition at " + fp + key = why + case !placed[module]: + why = fmt.Sprintf("the seat's assignment places no %s on this node (%d modules placed)", module, len(own.Modules)) + key = "not placed" + case !in: + why = fmt.Sprintf("no manifest in the composer's picture of the indexer's modules (%d modules, last update at revision %d)", + len(c.modules), c.modulesRev) + key = "no manifest" + case slices.Contains(skipped, module): + why = "the composition skipped it: the declaration gives it no fingerprint (indexer's picture: " + m.Fp2 + ")" + key = why + default: + if at := made[module]; at != "" { + why = fmt.Sprintf("the pass composed it at %s and the want names %s (indexer's picture: %s, revision %d)", at, fp, m.Fp2, c.modulesRev) + key = "composed at " + at + } else { + why = fmt.Sprintf("the composition made nothing of it at %s (indexer's picture: %s, revision %d)", fp, m.Fp2, c.modulesRev) + key = "made nothing at " + m.Fp2 + } + } + if w, ok := fromManifest[module]; ok { + why += "; " + w + key += "; " + w + } + out[module], keys[module] = why, fp+" "+key + } + for module, why := range c.notTaken { + if _, wanted := fps[module]; !wanted { + out[module] = "want not taken: " + why + keys[module] = out[module] + } + } + if len(out) == 0 { + return nil, keys + } + return out, keys +} + +// sayUnmade says what the pass composed none of when a module's reason changed (keys: its reason without where the +// picture of the indexer's modules stands, so a stuck want is not said again at every update of any module), and forgets a +// module once it is composed. +func (c *Composer) sayUnmade(unmade, keys map[string]string) { + c.mu.Lock() + changed := false + for m := range c.unmadeSaid { + if _, still := unmade[m]; !still { + delete(c.unmadeSaid, m) + } + } + for m, k := range keys { + if c.unmadeSaid[m] != k { + c.unmadeSaid[m], changed = k, true + } + } + c.mu.Unlock() + if !changed { + return + } + var lines []string + for _, m := range heldModules(unmade) { + lines = append(lines, m+": "+unmade[m]) + } + c.env.Say("the node composes nothing of " + strings.Join(lines, "; ")) +} + +// Waiting is the agent's word that it waits for the composition of module at fp, which it holds none of. When the composer +// believes it handed that composition, it forgets so and composes again, so the agent is handed it again: what was handed +// is no more trusted over what the agent holds (issue 1966, novox: handed zero, the agent waiting). +func (c *Composer) Waiting(module, fp string) { + key, err := engcomp.Key(c.env.Node, module, fp) + if err != nil { + return + } + c.mu.Lock() + _, believed := c.handed[key] + delete(c.handed, key) + c.mu.Unlock() + if !believed { + return + } + c.env.Say(fmt.Sprintf("the agent waits for the composition of %s at %s, which the node believed it handed: handed again", module, fp)) + select { + case c.rehand <- struct{}{}: + default: + } +} + +// WhyUnmade is why the last pass composed nothing of module at its want's fingerprint (Pass.Unmade); empty when it said +// none, or no pass was made. +func (c *Composer) WhyUnmade(module string) string { + c.mu.Lock() + defer c.mu.Unlock() + return c.last.Unmade[module] +} diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 701aa48..277f4e2 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -280,6 +280,72 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.mu.Unlock() t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey) } + // Issue 1966 (novox, 2026-11-09): every pass names each wanted module it composed nothing of, or composed from the indexer's + // manifest for want of a done build, with why, and a want it did not take; said once; the agent's word that it waits + // hands a composition the node believes handed again. + tfp := meshcatalogclient.SourcePrefix2 + strings.Repeat("7", 64) + tool := `{"module":"tool","version":"1","capabilities":["systemd"],"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"resources":[{"id":"service","type":"service","unit":"tool.service","state":"running","boot":"enabled"}]}` + tdigest := meshcatalogclient.ManifestDigest([]byte(tool)) + tman, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "tool", Manifest: tool}) + tkey, _ := meshcatalogclient.ManifestsKey(tdigest) + b.mu.Lock() + b.kv[buckets.Manifests+"/"+tkey] = tman + b.mu.Unlock() + tmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: tfp, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) + c.takeModule(agent.Update{Key: "tool", Value: tmod, Revision: 9}) + for module, w := range map[string]meshwants.WantsByWantNodeModuleValue{"tool": {Fp: tfp, Self: true}, "ghost": {Fp: fp, Self: true}, "nofp": {Self: true}} { + raw, _ := bucketstest.Want(w) + c.takeWant(agent.Update{Key: "want.n1." + module, Value: raw}) + } + c.mu.Lock() + placed := c.assignments["n1"] + placed.Modules = append(slices.Clone(placed.Modules), meshmoduleassigner.PlacedModule{Module: "tool"}) + c.assignments["n1"] = placed + c.mu.Unlock() + said := func(sub string) (n int) { + b.mu.Lock() + defer b.mu.Unlock() + for _, l := range b.said { + if strings.Contains(l, sub) { + n++ + } + } + return n + } + c.compose(ctx) + why := map[string]string{"tool": c.WhyUnmade("tool"), "ghost": c.WhyUnmade("ghost"), "nofp": c.WhyUnmade("nofp"), "lemurs": c.WhyUnmade("lemurs")} + if !strings.Contains(why["tool"], "the builder holds no done build of it at "+tfp) || !strings.Contains(why["ghost"], "places no ghost") || + !strings.Contains(why["nofp"], "want not taken: its want names no fingerprint") || why["lemurs"] != "" { + t.Errorf("the pass says it composed nothing of %q", why) + } + // An update of another module moves the picture's revision: the same reasons are not said again. + c.takeModule(agent.Update{Key: "lemurs", Value: mod, Revision: 10}) + c.compose(ctx) + if n := said("the node composes nothing of ghost"); n != 1 { + t.Errorf("the journal says why ghost is not composed %d times, want once", n) + } + // The state of this step is taken away again, and the pass that takes back what it handed is made here. + for _, module := range []string{"tool", "ghost", "nofp"} { + c.takeWant(agent.Update{Key: "want.n1." + module, Deleted: true}) + } + c.takeModule(agent.Update{Key: "tool", Deleted: true}) + c.mu.Lock() + placed.Modules = placed.Modules[:len(placed.Modules)-1] + c.assignments["n1"] = placed + c.mu.Unlock() + c.compose(ctx) + // The agent waits for what the node believes it handed: handed again; for what it never handed, nothing is. + c.Waiting("ghost", fp) + select { + case u := <-out: + t.Errorf("handed %s for a wait of a composition never handed", u.Key) + default: + } + c.Waiting("lemurs", fp) + if u := next(t, out); !strings.Contains(u.Key, "lemurs") { + t.Errorf("handed %s, want lemurs's composition again", u.Key) + } // ADR 0601 §1: the module composed today at the build it runs, as the agent compares it with what runs. The same inputs give // the digest handed; a changed input (here its manifest's service, as a changed setting would change a file) another; a // build that cannot be composed (no done build or manifest of it: held) gives none.