diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 6fb00de8..73072d92 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -260,16 +261,25 @@ func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) { } } -// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. +// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced. It invokes the +// hand-act seat's warranted and no verb of the controller, and the warrant is asked of that seat (ADR 0565 clause 11). func TestTheManifestListsEveryToolServed(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` + Tools []string `json:"tools"` + Invokes []string `json:"invokes"` } _ = json.Unmarshal(raw, &m) + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) { + t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes) + } + if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || + strings.Contains(string(src), "seat:mesh-controller.warranted") { + t.Error("recordWarrant does not ask the hand-act seat alone") + } listed := map[string]bool{} for _, n := range m.Tools { listed[n] = true diff --git a/modules/claude-code/cmd/claude-code/warrant_seat_test.go b/modules/claude-code/cmd/claude-code/warrant_seat_test.go deleted file mode 100644 index 07f4bfd8..00000000 --- a/modules/claude-code/cmd/claude-code/warrant_seat_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "slices" - "strings" - "testing" -) - -// The warrant is recorded through the seat mesh-hand-acts, which the manifest invokes, and no verb of the controller -// is invoked (ADR 0565 clause 11, issue 1953). -func TestTheWarrantIsRecordedThroughTheHandActSeat(t *testing.T) { - raw, err := os.ReadFile("../../module.json") - if err != nil { - t.Fatal(err) - } - var m struct { - Invokes []string `json:"invokes"` - } - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatal(err) - } - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) { - t.Errorf("invokes = %v", m.Invokes) - } - src, _ := os.ReadFile("proposals.go") - if !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || strings.Contains(string(src), "seat:mesh-controller.warranted") { - t.Error("recordWarrant does not ask the hand-act seat alone") - } -} diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 8b8de588..88186265 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -75,9 +75,8 @@ func (runtimeStore) Read(log string) ([]LogEntry, error) { func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) } -// recorder records into the hand-act log of the seat mesh-hand-acts (novox/hq ADR 0565 §11). The manifest invokes no verb -// of the controller (issue 1953), so the SDK recorder's fall back to the controller's log, while the seat has no holder, -// is refused by the bus and said in the answer: nothing is recorded in two logs. +// recorder records into the hand-act log of the seat mesh-hand-acts alone (novox/hq ADR 0565 §11, issue 1953): the manifest +// invokes no verb of the controller and the SDK recorder has no fall back to its log. var recorder = handacts.Recorder{Ask: stdio.Ask, LeaseAway: func() bool { e, err := stdio.State(LeaseRead).Get(buscompose.ControllerLeaseKey) var raw []byte diff --git a/modules/mesh-config/cmd/mesh-config/takeover.go b/modules/mesh-config/cmd/mesh-config/takeover.go index fbaba94d..39a6843a 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover.go +++ b/modules/mesh-config/cmd/mesh-config/takeover.go @@ -355,8 +355,11 @@ func (c *Config) compare() (*Comparison, error) { declaresNothing := map[string]bool{} for module, d := range mirror.Declared { if len(d.Config) == 0 { - if _, err := c.trunkDeclarations(module); err != nil { - declaresNothing[module] = true + // Only an indexer that was read and holds no modules.: a failed read says nothing, and the row stays. + if key, kerr := delivery.ModulesKey(module); kerr == nil { + if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil { + declaresNothing[module] = true + } } } } @@ -428,9 +431,11 @@ func (c *Config) compare() (*Comparison, error) { for _, k := range unionKeys(cands, own.Pulls) { a, aok := cands[k] b, bok := own.Pulls[k] - if c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled) { + isSettled, forgeNote := c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled) + if isSettled { continue // a merged or closed pull request is no candidate to agree on (the operator, 2026-11-01) } + before := len(diffs) switch { case !aok: diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"}) @@ -442,6 +447,9 @@ func (c *Config) compare() (*Comparison, error) { case declaresDigest(a.Declares) != declaresDigest(b.Declares): diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"}) } + if forgeNote != "" && len(diffs) > before { + diffs[len(diffs)-1].Why += "; " + forgeNote // the row stays because the forge was not heard: say so in the row + } } // Every placed module's value on every node, resolved both ways. @@ -502,22 +510,22 @@ func firstNonEmpty(a, b string) string { // pullSettled is whether the forge says a pull request merged or closed. Without a forge, or when it does not say, the // pull request counts as open, so its row stays (failure is loud). -func (c *Config) pullSettled(pull string, memo map[string]string) bool { +func (c *Config) pullSettled(pull string, memo map[string]string) (bool, string) { if c.Forge == nil || pull == "" { - return false + return false, "" } if v, ok := memo[pull]; ok { - return v != "" + return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v) } owner, repo, n, err := parsePull(pull) if err != nil { - return false + return false, "" } open, merged, _, err := c.Forge.Pull(owner, repo, n) switch { case err != nil: c.logf("[mesh-config] compare: the forge does not say whether %s is open (%v); its row stays", pull, err) - memo[pull] = "" + memo[pull] = forgeSilent + err.Error() case open: memo[pull] = "" case merged: @@ -525,7 +533,19 @@ func (c *Config) pullSettled(pull string, memo map[string]string) bool { default: memo[pull] = "closed" } - return memo[pull] != "" + v := memo[pull] + return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v) +} + +// forgeSilent marks a memo entry for a pull request the forge could not be asked about; the rest of the entry is why. +const forgeSilent = "forge-silent: " + +// forgeNoteOf is the words a row carries when the forge was not heard on its pull request, or "". +func forgeNoteOf(v string) string { + if why, ok := strings.CutPrefix(v, forgeSilent); ok { + return "the forge does not say whether the pull request is open (" + why + ")" + } + return "" } // normal is declarations in one encoding: a null default as none. diff --git a/modules/mesh-config/cmd/mesh-config/takeover_test.go b/modules/mesh-config/cmd/mesh-config/takeover_test.go index e4f9cdd0..1203c580 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover_test.go +++ b/modules/mesh-config/cmd/mesh-config/takeover_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "slices" "strings" "testing" @@ -92,6 +93,9 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) { candidate(t, m, "novox/mesh-catalog#11", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) candidate(t, m, "novox/mesh-catalog#10", "h2", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) }, []string{"pull:novox/mesh-catalog#10"}}, + {"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) { + candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`) + }, []string{"pull:novox/mesh-catalog#12"}}, {"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) { m.assign(t, "g14", "mesh-wireguard") m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`) @@ -100,6 +104,9 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) { for _, tc := range cases { c, m, r := anAgreeingMesh(t) c.Forge = &standForge{state: map[string][2]bool{"novox/mesh-catalog#11": {false, true}}} + if strings.Contains(tc.name, "forge that cannot be asked") { + c.Forge = &standForge{err: errors.New("forge down")} + } tc.change(t, m) cmp, err := c.Compare() if err != nil { @@ -112,6 +119,9 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) { if d.Why == "" { t.Errorf("%s: a difference without its reason: %+v", tc.name, d) } + if strings.Contains(tc.name, "forge that cannot be asked") && !strings.Contains(d.Why, "forge down") { + t.Errorf("%s: the row does not say the forge was not heard: %q", tc.name, d.Why) + } } a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"}) if len(tc.want) > 0 { diff --git a/modules/pacman/README.md b/modules/pacman/README.md index d1bae6ba..d5df40c1 100644 --- a/modules/pacman/README.md +++ b/modules/pacman/README.md @@ -30,7 +30,7 @@ that seat (ADR 0207). `package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and `.not-current` says the size and since when, which the health says too. `pacman_packages`, `pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no - module declares from the node-engine's `packages`, without asking a machine. + module declares from the node-engine's `packages`, else the controller's `declared-packages` until its lease is written, without asking a machine. - **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the state `runs` at `.`, written at each step and emitted as it: the package record taken and read back (`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own diff --git a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go index aa0383c2..917f8096 100644 --- a/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go +++ b/modules/pacman/cmd/pacman-tools/keeper_manifest_test.go @@ -133,7 +133,7 @@ func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) { } // The verbs read which packages each node's modules declare; the process reads no other module's state // (ADR 0507 §5, ADR 0436 §1). - if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"node-engine.packages"}) { + if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{"node-engine.packages", "mesh-controller.declared-packages"}) { t.Errorf("reads: %v", got) } } diff --git a/modules/pacman/cmd/pacman-tools/view.go b/modules/pacman/cmd/pacman-tools/view.go index 1980bd6b..fc8c2e32 100644 --- a/modules/pacman/cmd/pacman-tools/view.go +++ b/modules/pacman/cmd/pacman-tools/view.go @@ -18,7 +18,17 @@ import ( // DeclaredPackagesState is the node-engine's state of the packages its node's modules declare, one key per node, // written by that node's engine alone (ADR 0565 clause 6, which moved ADR 0507 §5's mirror off the controller), read // through the manifest's `reads`. -const DeclaredPackagesState = "node-engine." + delivery.EnginePackagesState +const DeclaredPackagesState = SourceEngine + +// ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node +// whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6). +const ControllerDeclaredState = "mesh-controller.declared-packages" + +// The sources a node's declaration is read from, said in the answer. +const ( + SourceEngine = "node-engine.packages" + SourceController = "mesh-controller.declared-packages" +) // declaredValue is a node's key of `packages`, in mesh-sdk's layout (the controller's mirror unchanged). type declaredValue = delivery.DeclaredPackages @@ -107,28 +117,35 @@ func (m *Machine) installedOn(node string) ([]nodeInstalled, error) { return out, nil } -// declaredOn is what each node's modules declare present, by node and package, with the declaring modules; a -// node whose engine has written no key is missing. -func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, error) { - r := m.States(DeclaredPackagesState) +// declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source +// that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. +func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, error) { out := map[string]map[string][]string{} - for _, n := range nodes { - var v declaredValue - found, err := readValue(r, n, &v) - if err != nil { - return nil, err - } - if !found { - continue - } - out[n] = map[string][]string{} - for _, p := range v.Packages { - if !p.Absent { - out[n][p.Package] = p.Modules + from := map[string]string{} + for _, source := range []string{SourceEngine, SourceController} { + r := m.States(source) + for _, n := range nodes { + if from[n] != "" { + continue + } + var v declaredValue + found, err := readValue(r, n, &v) + if err != nil { + return nil, nil, err + } + if !found { + continue + } + from[n] = source + out[n] = map[string][]string{} + for _, p := range v.Packages { + if !p.Absent { + out[n][p.Package] = p.Modules + } } } } - return out, nil + return out, from, nil } // NodeView is one node in the view. @@ -142,9 +159,11 @@ type NodeView struct { Current bool `json:"current"` NotCurrent *NotCurrent `json:"not_current,omitempty"` // Undeclared is the packages installed explicitly from a repository that no module assigned to the node - // declares; null when the node-engine has published no declaration of the node. + // declares; null when neither the node-engine nor the controller's mirror holds a declaration of the node. Undeclared []PackageVersion `json:"undeclared"` Foreign []PackageVersion `json:"foreign"` + // DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. + DeclaredFrom string `json:"declared_from,omitempty"` } // Spread is one package across the nodes: its version on each node that has it. @@ -163,6 +182,8 @@ type PackageOnNode struct { Foreign bool `json:"foreign,omitempty"` DeclaredBy []string `json:"declared_by"` Current bool `json:"current"` + // DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. + DeclaredFrom string `json:"declared_from,omitempty"` } // Packages is pacman_packages: one package on every node, or the whole view: per node its counts, its explicit @@ -177,14 +198,14 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) { for _, ni := range all { nodes = append(nodes, ni.Node) } - declared, err := m.declaredOn(nodes) + declared, sources, err := m.declaredOn(nodes) if err != nil { return nil, err } if name != "" { on := []PackageOnNode{} for _, ni := range all { - p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name]} + p := PackageOnNode{Node: ni.Node, Current: ni.NotCurrent == nil, DeclaredBy: declared[ni.Node][name], DeclaredFrom: sources[ni.Node]} if p.DeclaredBy == nil { p.DeclaredBy = []string{} } @@ -205,7 +226,7 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) { versions := map[string]map[string]string{} for _, ni := range all { v := NodeView{Node: ni.Node, At: ni.Value.At, Packages: len(ni.Value.Packages), Current: ni.NotCurrent == nil, - NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}} + NotCurrent: ni.NotCurrent, Foreign: []PackageVersion{}, DeclaredFrom: sources[ni.Node]} d, known := declared[ni.Node] if known { v.Undeclared = []PackageVersion{} diff --git a/modules/pacman/cmd/pacman-tools/view_test.go b/modules/pacman/cmd/pacman-tools/view_test.go index afcea49e..1c6873f3 100644 --- a/modules/pacman/cmd/pacman-tools/view_test.go +++ b/modules/pacman/cmd/pacman-tools/view_test.go @@ -40,7 +40,7 @@ func viewStates(t *testing.T) (*fakeStates, *Machine) { } func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { - _, m := viewStates(t) + states, m := viewStates(t) r, err := m.Packages("zsh", 500) if err != nil { t.Fatal(err) @@ -50,6 +50,22 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { t.Fatalf("%+v", on) } a, b := on[0], on[1] + if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" { + t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom) + } + // A node whose engine has written no key reads the controller's mirror, said so; a node with both reads its engine's. + var legacy, stale declaredValue + json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy) + json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale) + mirror := states.store(ControllerDeclaredState) + mirror.Put("node-b", legacy) + mirror.Put("node-a", stale) + r2, _ := m.Packages("zsh", 500) + on2 := r2["nodes"].([]PackageOnNode) + if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" || + on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" { + t.Errorf("fallback: %+v", on2) + } if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" || strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current { t.Errorf("node-a: %+v", a) diff --git a/modules/pacman/module.json b/modules/pacman/module.json index ae23589e..0aa330d4 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -12,7 +12,8 @@ } ], "reads": [ - "node-engine.packages" + "node-engine.packages", + "mesh-controller.declared-packages" ], "uses": [ "operator-channel"