diff --git a/cmd/mesh-controller/outcome_off_the_loop_test.go b/cmd/mesh-controller/outcome_off_the_loop_test.go index f8bdad1b..940745d9 100644 --- a/cmd/mesh-controller/outcome_off_the_loop_test.go +++ b/cmd/mesh-controller/outcome_off_the_loop_test.go @@ -104,3 +104,39 @@ func TestTheRecordsSettleAnOutcomeWithWhatTheBuildDid(t *testing.T) { t.Fatalf("an outcome's words were kept for a second settle: %q", got) } } + +// 1708/AC5: given a serving controller (its look at the plans running), when an outcome was taken into the plans, +// then moving them (a release's sends, which may retry for minutes) is left to the look, off the event loop +// (2026-10-21 14:28:40 to 14:32:10: the loop sent okioki's release to g14 after mesh-delivery-builder's outcome). +func TestAnOutcomeLeavesMovingThePlansToTheLook(t *testing.T) { + l := withLooker(t) + moved := false + advanceAfterOutcome(func() { moved = true }) + if moved { + t.Fatal("the plans were moved on the event loop after an outcome, where a send that retries holds every message") + } + select { + case <-l.signal: + default: + t.Fatal("the look was not asked to move the plans after an outcome") + } +} + +// 1708/AC5: given no look running (a command, which has no event loop), when an outcome was taken, then the plans are +// moved at once, as before. +func TestAnOutcomeMovesThePlansWhereNoLookRuns(t *testing.T) { + lookerMu.Lock() + before := theLooker + theLooker = nil + lookerMu.Unlock() + t.Cleanup(func() { + lookerMu.Lock() + theLooker = before + lookerMu.Unlock() + }) + moved := false + advanceAfterOutcome(func() { moved = true }) + if !moved { + t.Fatal("with no look running, an outcome left the plans unmoved") + } +} diff --git a/cmd/mesh-controller/plan_looks.go b/cmd/mesh-controller/plan_looks.go index ded34bcb..f0098525 100644 --- a/cmd/mesh-controller/plan_looks.go +++ b/cmd/mesh-controller/plan_looks.go @@ -136,6 +136,18 @@ func askToLook() { } } +// askedToLook asks the serving controller's look at the plans, and says whether there was one to ask. +func askedToLook() bool { + lookerMu.RLock() + l := theLooker + lookerMu.RUnlock() + if l == nil { + return false + } + l.ask() + return true +} + // startPlanLooks starts the look at the open plans, and asks for the first: the controller took the lease, and a // plan left by a replaced controller resumes from there (ADR 0406 §6). It replaces the rollout's 30 second ticker. // It answers a channel that is closed once the look has returned after the context ended, for a test that restores what diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 0ddb310b..7112e812 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -761,7 +761,19 @@ func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed s fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note) } } - advanceHeld(ctx, open) + advanceAfterOutcome(func() { advanceHeld(ctx, open) }) +} + +// advanceAfterOutcome moves the plans after an outcome was taken into them: **in the serving controller, by asking +// the look** (novox/hq issue 1708). The outcome is taken on the event loop, and moving the plans may send machines +// their declarations, a release's send retried for minutes among them; done here it held every report, build and +// merge behind it (2026-10-21 14:28:40 to 14:32:10). The look runs off the loop, holding the plans itself. Where no +// look runs (a command, which has no event loop) the plans are moved at once, as before. +func advanceAfterOutcome(advance func()) { + if askedToLook() { + return + } + advance() } // startedBeside is the id of a started walk open beside this one that it is related to — a merge's walk past