diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index d772d61..1af795b 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -601,6 +601,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess if composer != nil { composes.set(delivery.ComposesOwn) env.Composed, env.SelfComposed, env.Trusted, env.Policy = composer, true, nil, composer.Policy + env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does session.NoteComposes(linked, delivery.ComposesOwn, why) // And once the catalogue no longer places it, the agent begins again on the controller's compositions: the // placement key deleted ends this run, the way back (issue 1126, the seed's revert). diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 62df761..acb91db 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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() + } + } +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index f3c0b7e..a73faa2 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -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, ",") } } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index fb14265..b8bee19 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -743,6 +743,27 @@ func (c *Composer) Policy(ctx context.Context, module string) string { return policy } +// HoldsData says whether a module's manifest declares data (catalogue.Manifest.DataItems), as a composition's Data says +// it, and whether the manifest could be read: the agent's Data for what runs of a module from the kept declaration +// (agent.Env.HoldsData, issue 924). +func (c *Composer) HoldsData(ctx context.Context, module string) (data, known bool) { + c.mu.Lock() + m, ok := c.modules[module] + c.mu.Unlock() + if !ok || m.Manifest == "" { + return false, false + } + raw, err := reader{c: c}.Manifest(ctx, m.Manifest) + if err != nil { + return false, false + } + parsed, err := catalogue.ParseManifestForInputs(raw) + if err != nil { + return false, false + } + return len(parsed.DataItems()) > 0, true +} + // reader is the composer's view of the bus as frombus reads it. type reader struct{ c *Composer }