The node's words for a want it composes nothing of are said once per reason, and proved on the live paths (issue 1966)

The said-once keys no longer hold where the indexer's picture stands; a module composed from the manifest for want of a done
build is named in Pass.Unmade; the agent's wait is told once per want and again after the composition was there; the two tests
are folded into existing ones.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-09 09:04:45 +01:00
parent 5f37a43b91
commit 3dc4307aec
4 changed files with 168 additions and 96 deletions
+17 -5
View File
@@ -246,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
@@ -311,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{})}
@@ -1327,7 +1329,7 @@ func (a *Agent) HoldsNode() bool {
// 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) {
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 != "" {
@@ -1335,9 +1337,14 @@ func (a *Agent) waitsForComposer(module, fp string) {
}
}
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.said["composer:"+module] != line
a.sayOnce("composer:"+module, line)
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)
@@ -1359,9 +1366,14 @@ 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)
a.waitsForComposer(module, w.Want.Fp, w.Rev)
}
return // waits for the node's own composition of the want's build
}
+32 -20
View File
@@ -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,33 @@ 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.
want("web", fp, "")
b.wants.put(gk, graw)
eventually(t, "the composer was not told", func() bool { toldMu.Lock(); defer toldMu.Unlock(); return len(told) == 1 })
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 saidCount() != 1 || len(told) != 1 || told[0] != "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"}})
@@ -1935,23 +1967,3 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
t.Fatalf("a failed apply was tried again with no edge: %v", got)
}
}
// Issue 1966 (novox, 2026-11-09): an agent that waits for its node's own composition of a want says so once, with why the
// composer made none, and tells the composer, which hands again what it believes it handed.
func TestAnAgentWaitingForTheComposerSaysSoOnceAndTellsIt(t *testing.T) {
b := newBed(t)
b.env.SelfComposed = true
var said []string
b.env.Say = func(s string) { said = append(said, s) }
b.env.WhyUnmade = func(string) string { return "no manifest in the composer's picture" }
var told []string
b.env.Waiting = func(module, fp string) { told = append(told, module+"@"+fp) }
a := New(b.env)
for range 2 {
a.waitsForComposer("web", fpOf("a"))
}
want := "web waits for the composer: no composition of it at " + fpOf("a") + ": no manifest in the composer's picture"
if len(said) != 1 || said[0] != want || len(told) != 1 || told[0] != "web@"+fpOf("a") {
t.Errorf("said %q, told %q; want %q once and web told once", said, told, want)
}
}