J25 A-293: a placed module's failed newest build said as refused in by-entries mode, on the builder's build-ended event

Reading the catalog half by fingerprint, the node-engine no longer reads the builder's builds, so #224's refused key was
never written there and a reader waited on a build that will never be done. The engine now hears the builder's
build.ended stream, read whole at start then followed (as the assigner hears it); the last trunk end of a placed module
that ended failed is written as the refused key with the event's words, a later done end deletes it, an end naming a
branch moves nothing. The manifest consumes mesh-delivery-builder.build.ended.**.

Builds: design 47 G A-293 (with §421, §628), ADR 0593 §6, issue 1966 U1/U8

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 17:20:30 +01:00
parent 4ef07521eb
commit 905265a56b
9 changed files with 312 additions and 8 deletions
+49 -3
View File
@@ -91,8 +91,19 @@ func (c *Composer) publishComposed(ctx context.Context) {
func (c *Composer) newestFPs(ctx context.Context, at *published) (out map[string]string, failed map[string][2]string) {
if c.byEntries() {
// The newest build is the indexer's (and the builder's done key says it is built): neither is read, and the node
// follows no branch (design 47 G §421, §628), so the state holds the wanted builds alone.
return maps.Clone(at.fps), nil
// follows no branch (design 47 G §421, §628), so the state holds the wanted builds alone. A placed module's newest
// build that ended is the last trunk end the builder said of it (NoteBuildEnded): a failed one is refused (A-293).
failed = map[string][2]string{}
c.mu.Lock()
if c.endsRead {
for _, module := range at.placed {
if e, ok := c.ends[module]; ok && e.Outcome == builds.BuildFailed {
failed[module] = [2]string{e.Fingerprint, e.Error}
}
}
}
c.mu.Unlock()
return maps.Clone(at.fps), failed
}
out = map[string]string{}
failed = map[string][2]string{}
@@ -153,7 +164,10 @@ func (c *Composer) writeComposed(ctx context.Context, at *published) (wrote, del
wrote++
c.saidComposed(key, values[key])
}
for _, key := range c.pastKeeping() {
for _, key := range append(c.pastKeeping(), c.endedRefusals(values)...) {
if _, kept := c.pubKept[key]; !kept {
continue // past keeping and ended both
}
if err := c.env.ComposedState.Delete(ctx, key); err != nil {
return wrote, deleted, fmt.Errorf("%s cannot be deleted: %w", key, err)
}
@@ -271,6 +285,38 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin
return out, nil
}
// endedRefusals is, of the keys the state holds, each refusal a failed end of a module wrote (newestFPs) once that module's
// last trunk end is done, at any fingerprint (design 47 G A-293): the state never says refused for a done build. A key this
// pass writes (values), or one a want names, is not one. Under pubWrite.
func (c *Composer) endedRefusals(values map[string][]byte) []string {
c.mu.Lock()
defer c.mu.Unlock()
if !c.byEntries() || !c.endsRead {
return nil
}
var out []string
for module, last := range c.ends {
if last.Outcome != builds.BuildDone {
continue
}
for fp := range c.endFPs[module] {
key, err := engcomp.Key(c.env.Node, module, fp)
if err != nil || values[key] != nil || c.wants[module].Fp == fp {
continue
}
kept, ok := c.pubKept[key]
if !ok {
continue
}
if comp, err := engcomp.Decode(kept.Value); err == nil && comp.Refused != "" {
out = append(out, key)
}
}
}
sort.Strings(out)
return out
}
// composedKeyOf is a composition's key in the state.
func composedKeyOf(comp engcomp.Composed) (string, error) {
if comp.Module == "" {
@@ -2,6 +2,8 @@ package selfcompose
import (
"context"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
@@ -12,6 +14,7 @@ import (
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
engcomp "github.com/novox/mesh-host/internal/compositions"
"maps"
"slices"
"strings"
"sync"
@@ -258,4 +261,46 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
if past := keep.pastKeeping(); len(past) != 0 {
t.Errorf("past keeping with a want naming the oldest: %v, want none", past)
}
// Design 47 G A-293: reading the catalog half by fingerprint, a placed module's newest build that ended is the last trunk
// build.ended heard of it; a failed one is said refused with the event's words once the first read ended, an end naming a
// branch moves nothing, a module with no end (queued or running) stays unpublished, and a later done end, at any
// fingerprint, deletes the refused key.
byEnv := b.env()
byEnv.Entry = EntryCall(func(context.Context, string, string, any) (json.RawMessage, error) {
return nil, errors.New("nothing is asked")
})
byState := &fakeComposed{kv: map[string]ComposedEntry{}}
byEnv.ComposedState = byState
by := New(byEnv)
byAt := &published{fps: map[string]string{}, placed: []string{"broken", "waiting", "branched"}}
words := `refused kind: package of language "go"`
by.NoteBuildEnded(builds.BuildEnded{Module: "broken", Fingerprint: bfp, Outcome: builds.BuildFailed, Error: words})
by.NoteBuildEnded(builds.BuildEnded{Module: "branched", Fingerprint: wfp, Outcome: builds.BuildFailed, Error: "no", Branch: "feature"})
for _, step := range []struct {
name string
do func()
holds map[string]string // by key, the refusal it says
}{
{"before the first read ended", func() {}, map[string]string{}},
{"once the first read ended", by.BuildEndsRead, map[string]string{brokenKey: words}},
{"a later done end", func() {
by.NoteBuildEnded(builds.BuildEnded{Module: "broken", Fingerprint: wfp, Outcome: builds.BuildDone})
}, map[string]string{}},
} {
step.do()
if _, _, err := by.writeComposed(ctx, byAt); err != nil {
t.Fatalf("%s: %v", step.name, err)
}
byState.mu.Lock()
got := map[string]string{}
for k, v := range byState.kv {
comp, _ := engcomp.Decode(v.Value)
got[k] = comp.Refused
}
byState.mu.Unlock()
if !maps.Equal(got, step.holds) {
t.Errorf("%s: the state holds %v, want %v", step.name, got, step.holds)
}
}
}
+50
View File
@@ -204,6 +204,12 @@ type Composer struct {
// node's own consumer (NoteRunning), under mu; heardRead is whether the first read of the stream ended (RunningRead).
heard map[string]map[string]heardRun
heardRead bool
// ends is, by module, the last build end naming no branch the builder said of it (NoteBuildEnded), its newest build that
// ended, under mu; endsRead is whether the first read of the builder's stream ended (BuildEndsRead) (design 47 G A-293).
ends map[string]builds.BuildEnded
endsRead bool
// endFPs is, by module, every fingerprint a trunk end was heard of: the keys a failed end may have written.
endFPs map[string]map[string]bool
// engine is the node-engine's own module as the last composing of the node's input found it (Scope.Engine), under mu.
engine string
// rehand asks for a pass that hands again what the agent waits for (Waiting).
@@ -2443,6 +2449,50 @@ func (c *Composer) RunningLost() {
c.mu.Unlock()
}
// NoteBuildEnded takes the builder's event build.ended (design 47 G A-293): of a module, the last end naming no branch (the
// trunk's) stands as its newest build that ended, a failed one said as a refusal in the composition state, a done one ending
// that refusal; an end naming a branch moves nothing. Once the first read of the stream ended, an end of a module placed here
// asks a pass.
func (c *Composer) NoteBuildEnded(e builds.BuildEnded) {
if e.Module == "" || e.Fingerprint == "" || e.Branch != "" {
return
}
c.mu.Lock()
if c.ends == nil {
c.ends, c.endFPs = map[string]builds.BuildEnded{}, map[string]map[string]bool{}
}
if c.endFPs[e.Module] == nil {
c.endFPs[e.Module] = map[string]bool{}
}
c.endFPs[e.Module][e.Fingerprint] = true
if had, ok := c.ends[e.Module]; ok && had.Fingerprint == e.Fingerprint && had.Outcome == e.Outcome && had.Error == e.Error {
c.mu.Unlock()
return
}
c.ends[e.Module] = e
ask := c.endsRead
c.mu.Unlock()
if ask {
c.askRehand()
}
}
// BuildEndsRead says the first read of the builder's stream ended: from now the composer acts on the ends it heard, and a
// pass is asked.
func (c *Composer) BuildEndsRead() {
c.mu.Lock()
c.endsRead = true
c.mu.Unlock()
c.askRehand()
}
// BuildEndsLost says the builder's stream is not followed any more: what was heard is not acted on until a new read ends.
func (c *Composer) BuildEndsLost() {
c.mu.Lock()
c.endsRead = false
c.mu.Unlock()
}
func (c *Composer) askRehand() {
select {
case c.rehand <- struct{}{}: