Files
mesh-host/internal/selfcompose/candidate.go
T
jschoubben 0bbdae1d9d
mesh/delivery merged: source.main moved to novox/mesh-host@27a956448c0a
mesh/check checking 0bbdae1d9dfb on 27a956448c0a
compose-candidate reads the indexer's candidate by its group key, not the check's key
The checker asks by the composition key, <group>.<heads hash> (ADR 0593 §7), but the
indexer keys a candidate by its group alone (delivery.CandidatesKey). The node-engine
read the ask's key as the bucket key, so every check's composition was refused on
every node with "the indexer holds no candidate" (mesh-catalog #599, 2026-11-02).

It now parses the ask, reads CandidatesKey(group), and answers unready when the
group's candidate has moved to other heads. Both tests' fixtures had captured the
wrong key; they now key by group, with a slash in the group name.

Quick fix: this lookup. Thorough fix (a shared SDK reader that takes a composition
key and returns the candidate at those heads) not taken: one reader, one repository.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-03 00:05:04 +01:00

310 lines
11 KiB
Go

package selfcompose
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"sort"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/compose/frombus"
)
// **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate.
// Given the check's composition key (`<group>.<heads hash>`), it reads the group's candidate from the indexer
// (delivery.CandidatesBucket, keyed by the group alone, delivery.CandidatesKey) at those heads, and composes itself
// twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed
// values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's
// fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left
// out). It answers per module placed here composed (its digest, and whether the candidate changes it), refused by the
// candidate, or held without it too, and the verdict of its own composition. It applies nothing and writes nothing: it
// composes on a copy of the shadow's inputs, so neither the shadow's answers nor the node's own composer move.
// errNotReady is a shadow that has not read every input yet.
var errNotReady = errors.New("this node's shadow has not read every input yet")
// ComposeCandidate is the node's answer to ask, within delivery.ComposeAnswerBound (or ctx's deadline, if sooner).
func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandidateAsk) delivery.ComposeCandidateAnswer {
began := time.Now()
s.mu.Lock()
c := s.c
s.mu.Unlock()
node := c.env.Node
a := delivery.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate}
refuse := func(why string) delivery.ComposeCandidateAnswer {
a.Verdict, a.Refused, a.Modules, a.Own = delivery.ComposeCandidateRefused, why, nil, delivery.ModuleComposed{}
a.Took = time.Since(began).Milliseconds()
return a
}
// A node that cannot compose now (its shadow not ready, a read the bus did not answer, the bound passed) answers
// unready: the checker takes it as no answer (ADR 0593 §9) by the verdict, the words only saying why.
unready := func(why string) delivery.ComposeCandidateAnswer {
a = refuse(why)
a.Verdict = delivery.ComposeCandidateUnready
return a
}
group, heads, err := delivery.ParseCompositionKey(ask.Candidate)
if err != nil {
return refuse(err.Error())
}
// The ask names the check (the composition key, `<group>.<heads hash>`); the indexer keys the group's candidate by
// the group alone (delivery.CandidatesKey) and holds its heads hash in the value.
ckey, err := delivery.CandidatesKey(group)
if err != nil {
return refuse(err.Error())
}
ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound)
defer cancel()
if !s.ready.Load() {
return unready(errNotReady.Error())
}
base := c.scratch()
raw, err := base.get(ctx, delivery.CandidatesBucket, ckey)
if err != nil {
return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
}
if raw == nil {
return refuse("the indexer holds no candidate " + ask.Candidate)
}
cand, err := delivery.DecodeCandidate(raw)
if err != nil {
return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err))
}
if cand.HeadsHash != heads {
// The group moved to other heads: the check asked is replaced, and this node composes not a candidate it was
// not asked about. No answer (ADR 0593 §9), never a verdict on the wrong heads.
return unready(fmt.Sprintf("the indexer's candidate of the group %s is at heads %s, not %s: the check asked is replaced", group, cand.HeadsHash, heads))
}
laid := c.scratch()
touched, err := laid.layCandidate(ctx, cand)
if err != nil {
return unready(err.Error())
}
var running []delivery.Composed
if c.env.Running != nil {
running = c.env.Running()
}
baseComps, baseHeld, baseErr := base.composeShadow(ctx, running, nil, nil)
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) {
if !ask.Input {
return
}
in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope)
if err != nil {
return
}
// The request's input carries no catalogue (composer.Request.Input): the cases give the manifests.
in.Catalogue = nil
for i := range in.Nodes {
in.Nodes[i].Kept = nil
}
if body, err := json.Marshal(in); err == nil {
a.Input = body
}
})
if ctx.Err() != nil {
return unready(fmt.Sprintf("the bound of %s passed before the node composed the candidate", delivery.ComposeAnswerBound))
}
switch {
case laidErr != nil && BusFailed(laidErr):
return unready("a read on the bus did not answer: " + laidErr.Error())
case laidErr != nil && baseErr != nil:
// The node composes none of its own without the candidate either: the candidate changes nothing of that.
a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{}
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, Words: laidErr.Error()}
a.Took = time.Since(began).Milliseconds()
return a
case laidErr != nil:
return refuse(laidErr.Error())
}
byModule := func(comps map[string]delivery.Composed) map[string]delivery.Composed {
out := map[string]delivery.Composed{}
for _, comp := range comps {
out[comp.Module] = comp
}
return out
}
was, now := byModule(baseComps), byModule(laidComps)
one := func(module string) (delivery.ModuleComposed, bool) {
m := delivery.ModuleComposed{Module: module}
if module == "" {
m.Module = delivery.HaveNodeWord
}
if why, held := laidHeld[module]; held {
m.State, m.Words = delivery.ModuleRefusedState, why
if _, before := baseHeld[module]; before || baseErr != nil {
m.State = delivery.ModuleHeldState
}
return m, true
}
comp, ok := now[module]
if !ok {
return m, false
}
dig, err := delivery.ComposedDigestOf(comp)
if err != nil {
m.State, m.Words = delivery.ModuleRefusedState, err.Error()
return m, true
}
m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig
if before, ok := was[module]; !ok {
m.Changed = true
} else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig {
m.Changed = true
}
return m, true
}
modules := map[string]bool{}
for m := range now {
modules[m] = m != ""
}
for m := range laidHeld {
modules[m] = m != "" && m != agent.NodeModule
}
names := make([]string, 0, len(modules))
for m, isModule := range modules {
if isModule {
names = append(names, m)
}
}
sort.Strings(names)
a.Modules = []delivery.ModuleComposed{}
refused := false
for _, m := range names {
if mc, ok := one(m); ok {
a.Modules = append(a.Modules, mc)
refused = refused || mc.State == delivery.ModuleRefusedState
}
}
if own, ok := one(""); ok {
a.Own = own
} else {
a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState,
Words: "the node composes no composition of its own"}
}
refused = refused || a.Own.State == delivery.ModuleRefusedState
a.Verdict = delivery.ComposeCandidateComposed
if refused {
a.Verdict = delivery.ComposeCandidateRefused
}
a.Took = time.Since(began).Milliseconds()
return a
}
// scratch is a composer over a copy of what c holds of its inputs, for composing in shadow without moving c.
func (c *Composer) scratch() *Composer {
out := New(c.env)
c.mu.Lock()
defer c.mu.Unlock()
out.placements = maps.Clone(c.placements)
out.records = maps.Clone(c.records)
out.inOwn = maps.Clone(c.inOwn)
out.modules = maps.Clone(c.modules)
out.values = maps.Clone(c.values)
out.manifests = maps.Clone(c.manifests)
out.archives = maps.Clone(c.archives)
out.builds = maps.Clone(c.builds)
out.sealedOwn = maps.Clone(c.sealedOwn)
out.ownHanded = maps.Clone(c.ownHanded)
out.certified = c.certified
out.placementKeys = c.placementKeys
return out
}
// layCandidate lays the candidate's modules over the indexer's: each at its fingerprint, with its manifest's digest and
// the indexer's verdict on it (read from its verdicts, none read taken as accepted); a module the candidate removes goes.
// It answers the modules laid.
func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (map[string]bool, error) {
touched := map[string]bool{}
for _, pull := range cand.Pulls {
for module, cm := range pull.Fps {
if cm.None2 == delivery.CandidateNoneNoModule {
c.mu.Lock()
delete(c.modules, module)
c.mu.Unlock()
continue
}
if cm.FP2 == "" {
continue // no fingerprint at the preview: composed as the trunk holds it
}
m := delivery.Module{FP2: cm.FP2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head.Head}
if cm.Manifest != "" {
key, err := delivery.VerdictsKey(cm.Manifest)
if err != nil {
return nil, err
}
raw, err := c.get(ctx, delivery.VerdictsBucket, key)
if err != nil {
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
}
if raw != nil {
v, err := delivery.DecodeManifestVerdict(raw)
if err != nil {
return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err)
}
m.Verdict, m.Problems, m.Warnings = v.State, v.Problems, v.Warnings
}
}
c.mu.Lock()
c.modules[module] = m
c.mu.Unlock()
touched[module] = true
}
}
return touched, nil
}
// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build
// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when
// given, is handed the scope.
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) {
node := c.env.Node
c.mu.Lock()
placement, placed := c.placements[node]
modules := maps.Clone(c.modules)
inOwn := c.inOwnOf(placement)
c.mu.Unlock()
if !placed {
return nil, nil, errors.New("this node's key in the seat's placement state is gone: it composes nothing of its own")
}
fps := shadowBuilds(running, placement, modules, inOwn)
// A module the laid candidate changes is composed at the candidate's build, though the node runs another.
for module := range fps {
if mod, ok := modules[module]; ok && laid[module] {
fps[module] = mod.FP2
}
}
kept := map[string]string{}
for module, fp := range fps {
if mod, ok := modules[module]; ok && mod.FP2 == fp {
continue
}
kept[module] = fp
}
wants := map[string]delivery.Want{agent.NodeModule: {}}
for module, fp := range fps {
wants[module] = delivery.Want{FP: fp}
}
c.mu.Lock()
c.wants = wants
c.mu.Unlock()
scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue}
if input != nil {
input(scope)
}
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
if err != nil {
return nil, nil, err
}
for key, comp := range comps {
comps[key] = c.keepRunning(comp, running)
}
return comps, held, nil
}