Owe a look when a module's change ends unjudged, so a module with nothing long-running is ok within a look of its apply
mesh/delivery merged: source.main moved to novox/mesh-host@5f16cf507a0f
mesh/check pass b32a27c115a2 on 0dfbe1a179f2 | unit 292 run, 1060 kept, 5 skipped | integration 295 run, 1062 kept, 4 skipped | over bound: unit (slow…

The facts look inside an apply runs while the module is still applying and judges nothing; the
running write then left xorg running and unknown until the five-minute statement, holding every
x11-display consumer behind it. The running write now nudges the health loop, which looks and says
at once. A declaration's report gets the same motion and composition check, and a removed module
leaves nothing marked unjudged (review of fix 15).
This commit is contained in:
2026-10-25 15:46:05 +01:00
parent c16e625a08
commit b32a27c115
5 changed files with 187 additions and 7 deletions
+40 -7
View File
@@ -77,6 +77,28 @@ func (s *haveState) healthOf(module string, f ModuleFact) delivery.Health {
return nothingJudged(f, s.looked)
}
// moved marks a module's health not yet judged when its fact f is a change under way, or names another composition than
// the fact before it: the last look judged what ran before. The caller holds the lock.
func (s *haveState) moved(f ModuleFact) {
if prev, known := s.modules[f.Module]; inMotion(f.Phase) || known && (prev.FP != f.FP || prev.Composed != f.Composed) {
delete(s.health, f.Module)
if s.unjudged == nil {
s.unjudged = map[string]bool{}
}
s.unjudged[f.Module] = true
}
}
// owed says some module left motion and no look judged it since. The caller holds the lock.
func (s *haveState) owed() bool {
for module, f := range s.modules {
if s.unjudged[module] && !inMotion(f.Phase) {
return true
}
}
return false
}
// takeLook keeps a look's health: of a module still in motion nothing, since the look judged what is being replaced;
// of every other module what the look found, which is then judged. The caller holds the lock.
func (s *haveState) takeLook(h *Health) {
@@ -199,10 +221,21 @@ func (q *Session) noteApply(r Report) bool {
for module := range next {
delete(s.removed, module) // added back: its have is written with its new phase
}
for module := range s.modules {
if _, held := next[module]; !held {
delete(s.unjudged, module)
}
}
for _, f := range next {
s.moved(f)
}
s.modules = next
if r.Health != nil {
s.takeLook(r.Health)
}
if s.owed() {
q.oweHealth()
}
n := nodeFacts(r)
s.node = &n
return true
@@ -418,16 +451,15 @@ func (q *Session) NoteModule(ctx context.Context, f ModuleFact) {
// A change under way, or a fact naming another composition than the one looked at, leaves the module's health not
// yet judged until a look after it: the last look judged what ran before (2026-10-25, the canary of the controller on
// novox held on a have saying applying and failed two seconds before its launch said healthy).
if prev, known := s.modules[f.Module]; inMotion(f.Phase) || known && (prev.FP != f.FP || prev.Composed != f.Composed) {
delete(s.health, f.Module)
if s.unjudged == nil {
s.unjudged = map[string]bool{}
}
s.unjudged[f.Module] = true
}
s.moved(f)
s.modules[f.Module] = f
delete(s.removed, f.Module)
// Out of motion and not judged: a look is owed now, not at the loop's next statement (review of fix 15, B1).
owe := !inMotion(f.Phase) && s.unjudged[f.Module]
s.mu.Unlock()
if owe {
q.oweHealth()
}
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
@@ -449,6 +481,7 @@ func (q *Session) NoteRemoved(ctx context.Context, module, build string) {
s.removed[module] = build
delete(s.modules, module)
delete(s.written, module)
delete(s.unjudged, module)
s.mu.Unlock()
q.mu.Lock()
bus := q.bus
+29
View File
@@ -590,3 +590,32 @@ func TestAHealthIsNotCarriedOverToAnotherComposition(t *testing.T) {
t.Fatalf("the old composition's look judged the new one: %+v", h)
}
}
// A declaration's report naming another composition of a module, with no health of its own, does not carry the old
// look's health over; and a module removed leaves nothing marked unjudged behind.
func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) {
l := newHaveLink()
q, _ := aHaveQueue(l)
ctx := context.Background()
q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1"}},
Health: healthOf(map[string]string{"a": StateUnhealthy})})
if h := l.have(t, "n1.a"); h.Health != delivery.HealthFailed {
t.Fatalf("the failed look was not said: %+v", h)
}
q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1", FP: "src2:" + strings.Repeat("c", 64)}}})
if h := l.have(t, "n1.a"); h.Health != delivery.HealthUnknown {
t.Fatalf("the old composition's look judged the new one: %+v", h)
}
select {
case <-q.HealthOwed():
default:
t.Fatal("a module out of motion and unjudged owed no look")
}
q.NoteRemoved(ctx, "a", "")
q.haves.mu.Lock()
left := q.haves.unjudged["a"]
q.haves.mu.Unlock()
if left {
t.Fatal("a removed module is still marked unjudged")
}
}
+20
View File
@@ -57,6 +57,8 @@ type Session struct {
// gone is closed when the link open now closes, and changed whenever a link opens or closes (WhenLinked).
gone chan struct{}
changed chan struct{}
// owed is HealthOwed's: a module left motion unjudged.
owed chan struct{}
// saying is held while a facts event is made and said, so they leave in the order they are made.
saying sync.Mutex
@@ -76,9 +78,27 @@ func (q *Session) init() {
}
q.epoch = time.Now().UnixMilli()
q.changed = make(chan struct{})
q.owed = make(chan struct{}, 1)
})
}
// HealthOwed is told, at most once until it is read, when a module's change ended and no look has judged it since: the
// health loop looks and says its statement at once rather than at its next tick or five minutes on, so a module with
// nothing long-running is ok within a look of its apply, not five minutes after it (2026-10-25, review of fix 15).
func (q *Session) HealthOwed() <-chan struct{} {
q.init()
return q.owed
}
// oweHealth nudges the health loop; a nudge already waiting is enough.
func (q *Session) oweHealth() {
q.init()
select {
case q.owed <- struct{}{}:
default:
}
}
// SayFacts says this node's facts on the link open now, in the node-engine's own order, and whether the bus took them.
// A facts event made with no link open is not kept: the next link says the facts as they are then.
func (q *Session) SayFacts(ctx context.Context, r Report) bool {