Merge pull request 'The node-engine says why it composes a wanted module at no composition, and hands again one its agent lacks (issue 1966, recovery)' (#216) from fix/1966-views-page-time into main

This commit was merged in pull request #216.
This commit is contained in:
2026-11-09 08:13:14 +00:00
7 changed files with 347 additions and 8 deletions
+1
View File
@@ -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) 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 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.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 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") session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
ag = agent.New(env) ag = agent.New(env)
+41 -1
View File
@@ -125,6 +125,11 @@ type Env struct {
Wants Watcher Wants Watcher
Composed Watcher Composed Watcher
Haves Haves 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 // 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 // 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. // 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 is a module the node ran before this agent started, so it has something to back up.
prior map[string]bool prior map[string]bool
said map[string]string 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 is the modules whose have says the node could not compose them: why, and the have before (NotComposed).
notComposed map[string]notComposedMark notComposed map[string]notComposedMark
// heldAlone is the modules the composer holds alone (NotComposedEach): what runs of each in the node's own // 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) {} 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{}, 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{}, 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), 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{})} 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 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) { func (a *Agent) settleOne(ctx context.Context, module string) {
a.mu.Lock() a.mu.Lock()
w := a.wants[module] 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) comp, have := a.compositionFor(module, w.Want.Fp)
a.mu.Unlock() a.mu.Unlock()
if a.env.SelfComposed { if a.env.SelfComposed {
if have {
a.mu.Lock()
delete(a.waited, module)
a.mu.Unlock()
}
if !have || comp.Err != nil { 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 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). // The composition the node composed for the want's build is the one it applies (ADR 0547 §2).
+40
View File
@@ -1556,6 +1556,11 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)}) 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"} } 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() stop := b.run()
defer stop() defer stop()
unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) { unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) {
@@ -1583,6 +1588,41 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(ck, craw) b.comps.put(ck, craw)
want("web", fp, "") want("web", fp, "")
eventually(t, "the node's own composition was not applied", func() bool { return len(b.appliedList()) == 1 }) 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 }) 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. // 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"}}) b.ag.KeptNow(context.Background(), map[string][]string{"web": {"web runs the tool runner's process here"}})
+5
View File
@@ -209,6 +209,11 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
case digest != "" && read == nil: 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) 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: 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) return view, fmt.Errorf("the bucket holds no composition of %s at %s", module, fp)
case read == nil: case read == nil:
read = &latest read = &latest
+14
View File
@@ -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 // 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. // runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere.
Kept func(module, words string) 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 // 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 // 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). // 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 { for _, pm := range p.Modules {
m, ok := modules[pm.Module] 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]) { 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 // 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, // 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 "+ 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)) "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 continue // nothing of it runs: composed from the indexer's manifest, as before
} }
resolved, err := built(pm.Module, m.Fp2, b) resolved, err := built(pm.Module, m.Fp2, b)
+180 -7
View File
@@ -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). // 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 builds map[string]builds.BuilderBuild
handed map[string]string // composed key to the digest handed 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 // 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. // is other bytes, so the one first had is kept while the generation stands, and the composition stays the same.
sealedOwn map[string]ownSealed 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 // 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. // composed at (frombus.Scope.Holds), for LastHeld's words.
holds map[string]compose.Hold 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 is how many compositions the pass handed the agent that differ from what it handed before.
Handed int `json:"handed"` 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 // 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(), 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{}, 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)} 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 again = nil
dirty = true dirty = true
c.env.Say(c.againLine()) c.env.Say(c.againLine())
case <-c.rehand:
dirty = true
case reply := <-c.asked: case reply := <-c.asked:
// Asked by hand (the verb compose): composed now; the growing bound is not restarted, so a pass that // Asked by hand (the verb compose): composed now; the growing bound is not restarted, so a pass that
// fails again waits longer, not less. // 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) { 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok { if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok {
@@ -707,18 +729,30 @@ func (c *Composer) takeWant(u agent.Update) {
} else { } else {
c.wants[agent.NodeModule] = meshwants.WantsByWantNodeModuleValue{} c.wants[agent.NodeModule] = meshwants.WantsByWantNodeModuleValue{}
} }
return return ""
} }
node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key) node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key)
if !ok || node != c.env.Node { if !ok || node != c.env.Node {
return return ""
} }
delete(c.notTaken, module)
w, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value) w, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value)
if u.Deleted || err != nil || w.Removed || w.Fp == "" { if u.Deleted || err != nil || w.Removed || w.Fp == "" {
delete(c.wants, module) 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 c.wants[module] = w
return ""
} }
func (c *Composer) takeAssignments(u agent.Update) { func (c *Composer) takeAssignments(u agent.Update) {
@@ -844,6 +878,7 @@ func (c *Composer) takeModule(u agent.Update) {
return return
} }
c.modules[name] = m c.modules[name] = m
c.modulesRev = u.Revision
} }
func (c *Composer) takeValue(u agent.Update) { func (c *Composer) takeValue(u agent.Update) {
@@ -946,6 +981,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
keptRuns := map[string][]string{} keptRuns := map[string][]string{}
var keptMu sync.Mutex var keptMu sync.Mutex
var holds map[string]compose.Hold 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) { scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) {
keptMu.Lock() keptMu.Lock()
defer keptMu.Unlock() defer keptMu.Unlock()
@@ -953,12 +989,16 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
keptRuns[module] = append(keptRuns[module], words) keptRuns[module] = append(keptRuns[module], words)
} }
c.sayOnceEach(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.mu.Lock()
c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed
c.mu.Unlock() c.mu.Unlock()
missed := c.missed.Load() 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) 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 // 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). // 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 { if ctx.Err() == nil {
missed = c.missed.Load() 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) err = c.missedIn(missed, err)
} }
} }
@@ -1013,6 +1053,9 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
} }
c.paid() c.paid()
pass.Held, pass.holds = held, holds 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 // 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, // 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). // 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 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]
}
+66
View File
@@ -280,6 +280,72 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
b.mu.Unlock() b.mu.Unlock()
t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey) 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 // 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 // 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. // build that cannot be composed (no done build or manifest of it: held) gives none.