diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 7aabec4..18e45a0 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -171,6 +171,10 @@ type Scope struct { // 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). takeInHeld bool + // Holds is told, after Compose read the input, what holds each module it holds by its manifest's verdict or its + // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, + // the composition verb). Nil: said nowhere. + Holds func(map[string]compose.Hold) } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -590,6 +594,16 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s if err != nil { return nil, nil, held, err } + if s.Holds != nil { + holds := map[string]compose.Hold{} + for name := range held { + if h, said := in.Holds[name]; said { + h.Build = fps[name] + holds[name] = h + } + } + s.Holds(holds) + } // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 ยง5). asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) if err != nil { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index a0f89ad..989c5ac 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -235,6 +235,9 @@ type Pass struct { Refused string `json:"refused,omitempty"` // Held is each module refused alone, with why. Held map[string]string `json:"held,omitempty"` + // 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 // 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 @@ -247,11 +250,34 @@ type Pass struct { Queued string `json:"queued,omitempty"` } -// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none. +// LastHeld is what the last pass held, each module with why: for one its manifest's verdict or its decoder holds, also +// that and the build it would be composed at, as the input a check's composer reads says it (compose.Input.Holds, +// issue 1921); nil before a pass or when it held none. func (c *Composer) LastHeld() map[string]string { c.mu.Lock() defer c.mu.Unlock() - return maps.Clone(c.last.Held) + out := maps.Clone(c.last.Held) + for name, h := range c.last.holds { + if why, held := out[name]; held { + out[name] = why + " (" + holdWords(h) + ")" + } + } + return out +} + +// holdWords is what holds a module and the build it would be composed at, in words. +func holdWords(h compose.Hold) string { + by := "held by " + h.By + switch h.By { + case compose.HeldByVerdict: + by = "held by its manifest's verdict" + case compose.HeldByDecoder: + by = "held by this node-engine's decoder" + } + if h.Build == "" { + return by + ", at no build" + } + return by + ", at the build " + h.Build } // askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat @@ -919,6 +945,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { // module's have, from which the watchman raises its condition. keptRuns := map[string][]string{} var keptMu sync.Mutex + var holds map[string]compose.Hold scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) { keptMu.Lock() defer keptMu.Unlock() @@ -926,7 +953,7 @@ 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 }} 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() @@ -985,7 +1012,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { return nil } c.paid() - pass.Held = held + pass.Held, pass.holds = held, holds // 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). diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index fc714ca..701aa48 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -508,6 +508,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if len(*told4) != 1 || !strings.HasPrefix((*told4)[0], "held power: ") || !strings.Contains((*told4)[0], `"shows" is none of them`) { t.Errorf("the agent was told %q, want power held alone, naming its item", *told4) } + // Issue 1921: the composition verb's held says what holds power and the build it would be composed at, as the input a + // check's composer reads says it. + if why := c4.LastHeld()["power"]; !strings.Contains(why, `"shows" is none of them`) || + !strings.Contains(why, "held by its manifest's verdict, at the build "+powerFP) { + t.Errorf("the last pass held power for %q, want its verdict and its build named", why) + } // Review of issue 774 (the kept read): mesh-wireguard, which the mesh provides and which never has a fingerprint, is // folded in the node's own and composed from the manifest mesh-sdk provides (overlay.ProvidedManifest), not from // what runs nor the kept declaration: with nothing running (the kept read retired, Running empty) the node's own