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
+21 -3
View File
@@ -694,6 +694,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
session.SetManifestOf(composer.ManifestOf) // the event running names the manifest of the build that runs
go followRunning(run, session, composer, say)
go followBuildEnds(run, session, composer, say)
apply.UseEngineModule(composer.EngineModule) // the guard of the engine's own trees fixes no name (§527)
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
env.Recovery = recoveryEnv(session, composer)
@@ -1204,11 +1205,28 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
// followRunning keeps the composer told what every node runs, from the seat node-engine's event running, for the run it is
// in: the stream or the link not being there is said and tried again after a backoff, never polled.
func followRunning(run context.Context, session *link.Session, composer *selfcompose.Composer, say func(string)) {
keepFollowing(run, "this node does not hear what the nodes run, and tries again: ", func(ctx context.Context) error {
return session.FollowRunning(ctx, composer.NoteRunning, composer.RunningRead)
}, composer.RunningLost, say)
}
// followBuildEnds keeps the composer told how the builder's builds ended, from its event build.ended, for the run it is in
// (design 47 G A-293): a placed module's newest build that ended failed is said as a refusal in the composition state.
func followBuildEnds(run context.Context, session *link.Session, composer *selfcompose.Composer, say func(string)) {
keepFollowing(run, "this node does not hear how the builder's builds end, so a failed build is not said as refused, and it tries again: ",
func(ctx context.Context) error {
return session.FollowBuildEnds(ctx, composer.NoteBuildEnded, composer.BuildEndsRead)
}, composer.BuildEndsLost, say)
}
// keepFollowing follows a stream with follow for the run, lost called each time it ends; why it ended is said once until it
// changes, and it is followed again after a backoff, never polled.
func keepFollowing(run context.Context, line string, follow func(context.Context) error, lost func(), say func(string)) {
said := ""
for try := 1; run.Err() == nil; try++ {
began := time.Now()
err := session.FollowRunning(run, composer.NoteRunning, composer.RunningRead)
composer.RunningLost()
err := follow(run)
lost()
if run.Err() != nil {
return
}
@@ -1216,7 +1234,7 @@ func followRunning(run context.Context, session *link.Session, composer *selfcom
try = 1
}
if why := fmt.Sprint(err); why != said {
say("this node does not hear what the nodes run, and tries again: " + why)
say(line + why)
said = why
}
select {
@@ -11,6 +11,7 @@ import (
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshrecovery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-host/internal/link"
@@ -131,6 +132,41 @@ func TestNatsTheRecoveryStepAsksHearsBoundsAndCatchesUpOverTheBus(t *testing.T)
case <-time.After(10 * time.Second):
t.Fatal("the bound's end, the bus's marker, was not heard")
}
// Design 47 G A-293: over the same session, the builder's build ends the composer follows: the stream read whole first (the
// last end of each module and fingerprint, in the stream's order), then the read's end, then each end said after it.
if _, err := b.js.AddStream(&nats.StreamConfig{Name: link.BuildEndsStream, Subjects: []string{link.BuildEndsSubject}, MaxMsgsPerSubject: 1}); err != nil {
t.Fatal(err)
}
ended := func(fp, outcome, why string) {
raw, _ := json.Marshal(builds.BuildEnded{Module: "db", Fingerprint: fp, Outcome: outcome, Error: why})
if _, err := b.js.Publish("mesh.stream.mesh-delivery-builder.event."+builds.BuildEndedEventName("db", fp), raw); err != nil {
t.Fatal(err)
}
}
ended("f1", builds.BuildFailed, "first")
ended("f1", builds.BuildFailed, "no go")
ended("f0", builds.BuildDone, "")
heard := make(chan string, 8)
follow, stopFollow := context.WithCancel(ctx)
defer stopFollow()
go func() {
_ = session.FollowBuildEnds(follow, func(e builds.BuildEnded) { heard <- e.Fingerprint + ":" + e.Outcome + ":" + e.Error },
func() { heard <- "read" })
}()
for i, want := range []string{"f1:failed:no go", "f0:done:", "read", "f2:failed:later"} {
if want == "f2:failed:later" {
ended("f2", builds.BuildFailed, "later")
}
select {
case got := <-heard:
if got != want {
t.Fatalf("build end %d heard %q, want %q", i, got, want)
}
case <-time.After(10 * time.Second):
t.Fatalf("build end %d, %q, was not heard", i, want)
}
}
}
// startSession is a session on the node's own connection, with no node agent run on it.