What runs from the kept declaration says Data as the module's manifest does, and only a have that runs counts at the start (issue 924)

The kept-declaration fallback built a composition without Data, so after a restart a data module (mongodb, mssql
on novox) was judged to move its data against its first composition and the operator was asked a false yes. Now
every such composition takes Data from the manifest (agent.Env.HoldsData, the composer's DataItems), and a module
whose manifest cannot be read is said to be unjudgeable rather than taken to hold none. The composition a have
names comes before the kept declaration in the judgement too.

loadHad takes a have only in the phases baseline takes as running (running, held, a composer's mark): one backing
up, applying, checking, failed or waiting names a composition not applied (review of 031e106). Two rows added to
the same table test; the count stays.
This commit is contained in:
2026-10-29 21:04:17 +01:00
parent d8de0a8853
commit f38ac87ac9
4 changed files with 116 additions and 13 deletions
+76 -10
View File
@@ -151,6 +151,11 @@ type Env struct {
// move of a module of policy record that recreates a container or moves its data waits for a person's yes in its
// want (ADR 0547 §3). Nil judges no move.
Policy func(ctx context.Context, module string) string
// HoldsData says whether a module's manifest declares data (catalogue.Manifest.DataItems), and whether its manifest
// could be read: what a composition's Data is, for what runs of a module from the kept declaration, which carries no
// Data of its own. A composition says Data, so without it a data module from the kept declaration is judged to move
// its data against its first composition after a restart (mongodb and mssql on novox, issue 924). Nil reads none.
HoldsData func(ctx context.Context, module string) (data, known bool)
}
// transientFor is how many transient retries say the phase applying before the have says failed: past them the bus has
@@ -172,6 +177,9 @@ type Agent struct {
// composition, read once at the start (loadHad): what runs of a module the agent has not taken yet, ahead of the kept
// declaration (issue 924).
had map[string]compNow
// keptData is, for each module of the kept declaration whose manifest could be read, whether it declares data
// (Env.HoldsData): the Data of what runs of it from the kept declaration.
keptData map[string]bool
// attempt is the last apply of a module that failed or is held, with the revisions it was made from.
attempt map[string]attempt
checked map[string]bool
@@ -230,7 +238,7 @@ func New(env Env) *Agent {
if env.Say == nil {
env.Say = func(string) {}
}
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{},
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{},
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{},
retry: make(chan []string, 8), reconcile: make(chan []string, 64), tries: map[string]int{}, again: make(chan againAsk, 64),
transient: make(chan struct{}, 1), stopped: make(chan struct{})}
@@ -808,10 +816,7 @@ func (a *Agent) others(except string) []delivery.Composed {
}
sort.Strings(names)
for _, m := range names {
c := delivery.Composed{Node: a.env.Node, Module: m, Resources: kept[m]}
if a.env.KeptFilled != nil {
c.Filled = a.env.KeptFilled()[m]
}
c, _ := a.keptComposed(m, kept[m])
if m == NodeModule {
c.Module = ""
}
@@ -846,12 +851,13 @@ func (a *Agent) loadHad(ctx context.Context) {
names = append(names, m)
}
sort.Strings(names)
a.readKeptData(ctx, names...)
for _, m := range names {
if ctx.Err() != nil {
return
}
h, ok := a.env.Haves.Read(ctx, m)
if !ok || h.Composed == "" || h.Phase == string(delivery.PhaseRemoved) {
if !ok || h.Composed == "" || !haveRuns(h) {
continue
}
a.mu.Lock()
@@ -871,6 +877,17 @@ func (a *Agent) loadHad(ctx context.Context) {
}
}
// haveRuns says a have names what runs, in the phases baseline takes as running: running (or empty), held for a failed
// backup, or a composer's mark over what still runs. A have backing up, applying, checking, failed or waiting names a
// composition not applied, or not yet (issue 924, review of 031e106).
func haveRuns(h Have) bool {
switch delivery.Phase(h.Phase) {
case "", delivery.PhaseRunning, delivery.PhaseHeld:
return true
}
return composerMark(h)
}
// keptHas says whether the kept declaration has resources for the module. Called holding a.mu or not: Kept takes no lock
// of the agent's.
func (a *Agent) keptHas(module string) ([]json.RawMessage, bool) {
@@ -1009,7 +1026,8 @@ func (a *Agent) heldOut(except string) []delivery.Composed {
}
}
if len(rs) > 0 {
out = append(out, delivery.Composed{Node: a.env.Node, Module: m, Resources: rs})
// Held out, never judged: its Data from its manifest where known, as every other composition says it.
out = append(out, delivery.Composed{Node: a.env.Node, Module: m, Resources: rs, Data: a.keptData[m]})
}
}
return out
@@ -1397,6 +1415,7 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
if module == NodeModule || a.env.Policy == nil || a.env.Policy(ctx, module) != catalogue.PolicyRecord || w.Want.Yes == comp.Dig {
return false
}
a.readKeptData(ctx, module) // the manifest may read now though it did not at the start
a.mu.Lock()
r, ran := a.running[module]
if ran && r.Composed == comp.Dig && r.Phase != delivery.PhaseRemoved {
@@ -1442,14 +1461,61 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
// runningComposition is what the module runs now: the composition last applied, else its resources in the declaration
// the node kept from before the switch. Called with a.mu held.
//
// The composition its have named at the start comes before the kept declaration (issue 924). From the kept declaration,
// what runs carries Data as its manifest declares it; a module whose manifest could not be read cannot be judged against
// it (false), and that is said, rather than taking it to hold no data and judging a false move of its data.
func (a *Agent) runningComposition(module string) (delivery.Composed, bool) {
if c, ok := a.applied[module]; ok && c.Err == nil {
return c.C, true
}
if a.env.Kept != nil {
if rs, ok := a.env.Kept()[module]; ok {
return delivery.Composed{Node: a.env.Node, Module: module, Resources: rs}, true
if c, ok := a.had[module]; ok {
return c.C, true
}
if rs, ok := a.keptHas(module); ok {
c, known := a.keptComposed(module, rs)
if !known {
a.sayOnce("kept-data:"+module, fmt.Sprintf("%s runs from the kept declaration, and whether it holds data cannot be "+
"read from its manifest: a move of it cannot be judged", module))
}
return c, known
}
return delivery.Composed{}, false
}
// keptComposed is what runs of a module from the kept declaration: its resources there, the paths it names as filled,
// and Data as the module's manifest declares it; false when the manifest could not be read, so Data is not known. Called
// with a.mu held.
func (a *Agent) keptComposed(module string, rs []json.RawMessage) (delivery.Composed, bool) {
c := delivery.Composed{Node: a.env.Node, Module: module, Resources: rs}
if a.env.KeptFilled != nil {
c.Filled = a.env.KeptFilled()[module]
}
data, known := a.keptData[module]
c.Data = data
return c, known || module == NodeModule
}
// readKeptData reads, outside the agent's lock, whether each module of the kept declaration not known yet declares data
// (Env.HoldsData), and keeps the answers it could read.
func (a *Agent) readKeptData(ctx context.Context, modules ...string) {
if a.env.HoldsData == nil {
return
}
for _, m := range modules {
if m == NodeModule {
continue
}
a.mu.Lock()
_, done := a.keptData[m]
a.mu.Unlock()
if _, kept := a.keptHas(m); done || !kept {
continue
}
if data, known := a.env.HoldsData(ctx, m); known {
a.mu.Lock()
a.keptData[m] = data
a.mu.Unlock()
}
}
}
+18 -3
View File
@@ -1031,6 +1031,9 @@ func TestTheRevisionAWantNamesIsAppliedFromHistory(t *testing.T) {
// for claude-code and screen-lock, 2026-10-25); from the composition its have names once it has one, the kept
// declaration being older, read at the start before any module settles and told to the filling then, so a file the
// newer composition fills is accepted on the first fill (power's three drop-ins on ace, g14 and shanks, 2026-10-29).
// Only a have in a phase baseline takes as running names what runs; and what runs carries Data, from the composition
// or, from the kept declaration, from the module's manifest, so a data module is not judged to move its data against
// its first composition after a restart (mongodb and mssql on novox).
func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
const old, added = "/tmp/r6/power/settings", "/tmp/r6/power/bound"
filledFile := func(id string) json.RawMessage {
@@ -1039,7 +1042,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
}
fp := fpOf("a")
ck, _ := delivery.ComposedKey(node, "power", fp)
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added},
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}}
_, appliedRaw, appliedDig := signedBy(t, applied, ck, controllerKey)
newer := applied
@@ -1053,13 +1056,21 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
bucket []byte
history []Update
filled, told string
// data is what runs' Data; holdsData the manifest's answer (nil: none read).
data bool
holdsData func(context.Context, string) (bool, bool)
}{
{name: "no have: the kept declaration's", filled: old},
{name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start"},
bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true},
{name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig,
Phase: string(delivery.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
{name: "a data module from the kept declaration says Data as its manifest does", filled: old,
told: "the haves were read at the start", data: true,
holdsData: func(context.Context, string) (bool, bool) { return true, true }},
{name: "a have naming a revision replaced since", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
bucket: newerRaw, history: []Update{{Key: ck, Value: appliedRaw, Revision: 3}, {Key: ck, Value: newerRaw, Revision: 7}},
filled: old + "," + added, told: "the haves were read at the start"},
filled: old + "," + added, told: "the haves were read at the start", data: true},
{name: "a have naming a composition the bucket no longer keeps", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
bucket: newerRaw, filled: old, told: "the haves were read at the start"},
} {
@@ -1074,12 +1085,16 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
b.comps.before(ck, tc.bucket)
}
b.env.Revisions = func(_ context.Context, key string) ([]Update, error) { return tc.history, nil }
b.env.HoldsData = tc.holdsData
var told []string
var mu sync.Mutex
b.env.RunsChanged = func(why string) { mu.Lock(); told = append(told, why); mu.Unlock() }
filledNow := func() string {
for _, c := range b.ag.Everything() {
if c.Module == "power" {
if c.Data != tc.data {
t.Errorf("what runs says Data %v, want %v", c.Data, tc.data)
}
return strings.Join(c.Filled, ",")
}
}