diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/door.go b/modules/artifact-store-tools/cmd/artifact-store-tools/door.go index 1f010869..4335d786 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/door.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/door.go @@ -88,7 +88,7 @@ func (runtimeBus) Publish(subject string, body any) error { return stdio.SeatPublish(subject, body, "") } -// follow watches the builder's done keys and the wants: a build's end sweeps (once the lease names no controller) and +// follow watches the builder's done keys and the wants: a build's end sweeps and // observes again; a want changed observes again. Event-driven: nothing reads them on a timer. func follow(h *Holder) { <-stdio.Served() diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go index 5325b52f..ee0c9242 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go @@ -9,8 +9,8 @@ package main // At each build's end (a done key written) it sweeps as the controller's take-in did: every kept archive held first // (ADR 0251 §8), then what is eligible let go of, oldest first, within the same bound per sweep, an index left for a // `collect` a person confirms (ADR 0251 §7), each deletion recorded in the hand-act log (seat mesh-hand-acts) and said -// as artifacts-gone. **Nothing is let go until the controller's lease names a holder that is no controller**: until -// then the controller's take-in sweeps, and two sweeps never run at once. It also observes S25, image-missing: a want's +// as artifacts-gone. **No lease turns it** (issue 1126): the controller ended by its unassignment, so its take-in sweeps +// nothing and this seat is the one sweep. It also observes S25, image-missing: a want's // image the store does not hold, a finding in its own state `findings` the condition keeper reads. import ( @@ -24,7 +24,6 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/handacts" @@ -40,7 +39,6 @@ const ( HavesRead = "node-engine.have" // ManifestsRead is the indexer's manifests: the images a manifest pins by itself. ManifestsRead = delivery.IndexerModule + ".manifests" - LeaseRead = buscompose.ControllerLease // FindingsState is the holder's own findings (health.FindingsState): image-missing. FindingsState = health.FindingsState // Module is this module, the observer its findings name. @@ -58,7 +56,7 @@ const ( ImageMissing = "image-missing" ) -// Bus is what the holder reads and writes on the bus: its read states (wants, builds, haves, the lease), its own +// Bus is what the holder reads and writes on the bus: its read states (wants, builds, haves), its own // findings, and its seat's event. The runtime in main; a test's in memory. type Bus interface { Keys(state string) ([]string, error) @@ -106,13 +104,6 @@ type Build struct { Manifest string } -// LeaseAway says the controller's lease names a holder that is no controller: then the seat sweeps. -func (h *Holder) LeaseAway() bool { - raw, found, err := h.Bus.Get(LeaseRead, buscompose.ControllerLeaseKey) - away, _ := buscompose.LeaseHandedAway(raw, found, err) - return away -} - // Builds is every done build the builder's keys hold, each with its end. func (h *Holder) Builds() ([]Build, error) { keys, err := h.Bus.Keys(BuildsRead) @@ -495,10 +486,6 @@ func (h *Holder) Collect(why string, confirm bool, most int) (*CollectAnswer, er if most > MostAtAll { return nil, fmt.Errorf("collect lets go of at most %d at once, not %d", MostAtAll, most) } - if confirm && !h.LeaseAway() { - return nil, errors.New("the controller holds its lease, and its take-in sweeps the store until it hands it away: " + - "this seat lets nothing go before then (novox/hq ADR 0571 clause 2); ask the controller's collect") - } return h.Sweep(context.Background(), Sweeping{Confirmed: confirm, DryRun: !confirm, Most: most, Why: why, By: "collect"}) } @@ -665,11 +652,8 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) { } } -// AfterBuild is the sweep at a build's end: only once the lease names no controller. +// AfterBuild is the sweep at a build's end. func (h *Holder) AfterBuild(key string) { - if !h.LeaseAway() { - return - } a, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "after the build " + key}) if err != nil { h.logf("the sweep after %s: %v", key, err) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go index d9d1b1ae..b97cfd9a 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go @@ -3,7 +3,7 @@ package main // The seat mesh-artifact-sweep's holder whole against a real bus (novox/hq ADR 0571 clause 2, issue 1955): the builder's done -// keys, the wants and the controller's lease in a real nats-server's key-value buckets, a build's end heard by a watch +// keys and the wants in a real nats-server's key-value buckets, no lease key anywhere (issue 1126), a build's end heard by a watch // of the builds as the runtime hands it over, each deletion recorded by a request to the seat mesh-hand-acts on the bus, // artifacts-gone said on the seat's subject, and S25's finding written to the holder's own bucket. Only the store's // door is in memory: its HTTP is the copied registry package's, tested there. @@ -20,7 +20,6 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -117,7 +116,7 @@ func (b realBus) Publish(subject string, body any) error { return b.nc.Publish(subject, raw) } -func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { +func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() nc, err := nats.Connect(testbus.URL(t)) @@ -129,7 +128,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { if err != nil { t.Fatal(err) } - for _, s := range []string{BuildsRead, WantsRead, HavesRead, LeaseRead, ManifestsRead, "artifact-store-tools." + FindingsState} { + for _, s := range []string{BuildsRead, WantsRead, HavesRead, ManifestsRead, "artifact-store-tools." + FindingsState} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucketOf(s), History: 5}); err != nil { t.Fatal(err) } @@ -137,7 +136,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { bus := realBus{ctx: ctx, nc: nc, js: js} // The mesh as aMesh lays it, copied onto the real buckets: letta's seven builds, a want, the store's listing. - mem, memb, door, _ := aMesh(t, &buscompose.LeaseHolder{Instance: "c1", Host: "novox", Epoch: 3}) + mem, memb, door, _ := aMesh(t) // The builder's log as written today: aMesh dates it from 2026-10-01 for its fixed clock, and this holder runs on the // real clock, so read as dated the entries fall out of the log's 30 days and every copy is kept as unrecorded. shift := time.Since(memb.log[len(memb.log)-1].At) @@ -145,7 +144,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { e.At = e.At.Add(shift) bus.log = append(bus.log, e) } - for _, s := range []string{BuildsRead, WantsRead, LeaseRead, ManifestsRead} { + for _, s := range []string{BuildsRead, WantsRead, ManifestsRead} { keys, _ := memb.Keys(s) for _, k := range keys[:min(len(keys), 6)] { // the seventh build ends later, on the bus raw, _, _ := memb.Get(s, k) @@ -175,7 +174,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { t.Fatal(err) } h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Now: time.Now, Logf: t.Logf} - h.Acts = handacts.Recorder{LeaseAway: h.LeaseAway, Ask: func(key string, body any) (json.RawMessage, error) { + h.Acts = handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { raw, _ := json.Marshal(body) m, err := nc.Request("mesh.seat."+strings.TrimPrefix(strings.Replace(key, ".", ".tool.", 1), "seat:"), raw, 5*time.Second) if err != nil { @@ -204,16 +203,8 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) { } } - // While the lease names the controller, a build's end lets nothing go. - ended() - if len(door.asked) != 0 { - t.Fatalf("before the lease write the store was asked %v", door.asked) - } - // The lease handed away: the next build's end holds the kept archives and lets go of letta's 2nd, which the 7th moved - // past the five newest and no want names (the want keeps the 1st), recording each deletion. - if err := bus.Put(LeaseRead, buscompose.ControllerLeaseKey, buscompose.LeaseHolder{HandedTo: "mesh-grants", Why: "the lease write"}); err != nil { - t.Fatal(err) - } + // No lease key: the build's end holds the kept archives and lets go of letta's 2nd, which the 7th moved past the five + // newest and no want names (the want keeps the 1st), recording each deletion. ended() deadline := time.Now().Add(30 * time.Second) for { diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go index f1d6b7a1..eac183db 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go @@ -11,7 +11,6 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" @@ -139,8 +138,8 @@ func archiveRef(module string, n int) string { } // A mesh with seven done builds of letta, the n-th ending n hours after a day began, each with an image and an archive; -// a want names the first on ace; the store lists them all and one image no build names; the lease as given. -func aMesh(t *testing.T, lease *buscompose.LeaseHolder) (*Holder, *memBus, *memDoor, *[]string) { +// a want names the first on ace; the store lists them all and one image no build names; no lease key (issue 1126). +func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) { t.Helper() day := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) b := &memBus{states: map[string]map[string][]byte{}, history: map[string][]stdio.StateRevision{}} @@ -194,9 +193,6 @@ func aMesh(t *testing.T, lease *buscompose.LeaseHolder) (*Holder, *memBus, *memD _ = b.Put(ManifestsRead, mk, json.RawMessage(manifest)) want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true}) _ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want)) - if lease != nil { - _ = b.Put(LeaseRead, buscompose.ControllerLeaseKey, lease) - } var acts []string h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil }, @@ -206,18 +202,14 @@ func aMesh(t *testing.T, lease *buscompose.LeaseHolder) (*Holder, *memBus, *memD acts = append(acts, key+" "+string(raw)) return json.RawMessage(`{}`), nil }}} - h.Acts.LeaseAway = h.LeaseAway return h, b, door, &acts } -// ADR 0571 clause 2: the keep set from the store's listing, the wants and the five newest builds; nothing let go of while -// the lease names a controller; once it names another holder, a build's end lets go of the eligible within the bound, -// leaves an index for a confirmed collect, records each deletion and says artifacts-gone. -func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsOnlyOnceTheLeaseIsAway(t *testing.T) { - controller := &buscompose.LeaseHolder{Instance: "c1", Host: "novox", Epoch: 3} - handed := &buscompose.LeaseHolder{HandedTo: "mesh-grants", HandedBy: "person.jochen", Why: "the lease write"} - - h, _, _, _ := aMesh(t, controller) +// ADR 0571 clause 2, 1126/A4/AC2: the keep set from the store's listing, the wants and the five newest builds; with no +// lease key, a build's end and a confirmed collect let go of the eligible within the bound, leave an index for a +// confirmed collect, record each deletion as a hand act and say artifacts-gone. +func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) { + h, _, _, _ := aMesh(t) r, err := h.Artifacts("") if err != nil { t.Fatal(err) @@ -244,9 +236,10 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsOnlyOnceTheLeaseIsAway(t t.Errorf("the references are %d, counted %v, at %q", len(r.References), r.Counts, r.Store) } + all := []string{archiveRef("letta", 2), imageRef("letta", 2), mirror(1)} for _, c := range []struct { name string - lease *buscompose.LeaseHolder + how string // "build" (a build's end), "collect" (a confirmed collect), or a sweep asked with most index bool most int letGo []string @@ -254,45 +247,58 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsOnlyOnceTheLeaseIsAway(t skipped int unlog int }{ - {name: "the lease names a controller", lease: controller}, - {name: "no lease key", lease: nil}, - {name: "handed away", lease: handed, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2), mirror(1)}}, - {name: "an index waits", lease: handed, index: true, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), mirror(1)}, skipped: 1}, - {name: "bounded", lease: handed, most: 1, letGo: []string{archiveRef("letta", 2)}, left: 2}, + {name: "a build's end", how: "build", letGo: all}, + {name: "a confirmed collect", how: "collect", letGo: all}, + {name: "a sweep", most: MostPerSweep, letGo: all}, + {name: "an index waits", index: true, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), mirror(1)}, skipped: 1}, + {name: "bounded", most: 1, letGo: []string{archiveRef("letta", 2)}, left: 2}, // A kept build the log no longer holds: what it stood on is unknown, so no copy goes. - {name: "a kept build's bases unknown", lease: handed, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3}, + {name: "a kept build's bases unknown", most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3}, } { t.Run(c.name, func(t *testing.T) { - h, b, door, acts := aMesh(t, c.lease) + h, b, door, acts := aMesh(t) door.indexes[imageRef("letta", 2)] = c.index if c.unlog > 0 { b.log = append(b.log[:c.unlog-1:c.unlog-1], b.log[c.unlog:]...) } - if c.most == 0 { + switch c.how { + case "build": h.AfterBuild("letta.x") - if _, err := h.Collect("tidy", true, 0); err == nil { - t.Error("a confirmed collect was let through while the controller sweeps") + var gone []string + for _, x := range door.asked { + if r, ok := strings.CutPrefix(x, "let go "); ok { + gone = append(gone, r) + } } - if len(door.asked) != 0 || len(*acts) != 0 || len(b.published) != 0 { - t.Fatalf("before the lease write the store was asked %v, acts %v, said %v", door.asked, *acts, b.published) + if fmt.Sprint(gone) != fmt.Sprint(c.letGo) { + t.Fatalf("a build's end let go of %v, want %v", gone, c.letGo) + } + default: + var a *CollectAnswer + var err error + if c.how == "collect" { + a, err = h.Collect("tidy", true, 0) + } else { + a, err = h.Sweep(context.Background(), Sweeping{Most: c.most, By: "after the build letta.x"}) + } + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != 6 || a.Missing != 0 { + t.Fatalf("let go of %v (left %d, skipped %d, held %d, missing %d), want %v", a.LetGo, a.Left, a.Skipped, a.Held, a.Missing, c.letGo) } - return - } - a, err := h.Sweep(context.Background(), Sweeping{Most: c.most, By: "after the build letta.x"}) - if err != nil { - t.Fatal(err) - } - if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != 6 || a.Missing != 0 { - t.Fatalf("let go of %v (left %d, skipped %d, held %d, missing %d), want %v", a.LetGo, a.Left, a.Skipped, a.Held, a.Missing, c.letGo) } // Every kept archive held before the first deletion. - first := -1 - for i, x := range door.asked { - if strings.HasPrefix(x, "let go ") && first < 0 { - first = i + held := 0 + for _, x := range door.asked { + if strings.HasPrefix(x, "let go ") { + break + } + if strings.HasPrefix(x, "hold ") { + held++ } } - if first != 6 { + if held != 6 { t.Errorf("the store was asked %v: the six kept archives are held first", door.asked) } if len(*acts) != len(c.letGo) || !strings.HasPrefix((*acts)[0], "seat:mesh-hand-acts.hand-act ") || @@ -310,7 +316,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsOnlyOnceTheLeaseIsAway(t // S25 (image-missing): a want whose build names an image the store does not hold is a finding in the seat's own state, // written once and deleted once the store holds it again; images names what a machine's wants and haves name. func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testing.T) { - h, b, _, _ := aMesh(t, nil) + h, b, _, _ := aMesh(t) view, _ := h.Listed(context.Background()) repo, digest, _ := splitReference(imageRef("letta", 1)) delete(view.L.Repos[repo].Revisions, digest) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go index ea1f28e0..8eb92fba 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go @@ -1,8 +1,7 @@ // The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, ADR 0571 clause 2, to-be 51): a process the // node's runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its repositories, its // size, and what the mesh keeps of each manifest — read from the store's own files through the store's own container, -// and it holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end once the controller's lease names -// no controller, and S25's image-missing. +// and it holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end, and S25's image-missing. // // **A module beside the store, not the store's own.** A module that provides the artifact store cannot also build an // artifact: building publishes to the store, so the store would be needed to create itself. So these tools are a @@ -29,7 +28,7 @@ func main() { } h := &Holder{Bus: runtimeBus{}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View, Now: time.Now, Logf: logf} - h.Acts = handacts.Recorder{Ask: stdio.Ask, LeaseAway: h.LeaseAway} + h.Acts = handacts.Recorder{Ask: stdio.Ask} go follow(h) // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools. if err := stdio.Serve("", append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...)); err != nil { diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go index e0617a03..87ba350f 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go @@ -436,7 +436,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { t.Fatalf("served %v, manifest %v", served, listed) } // ADR 0571 clause 2: it holds the seat mesh-artifact-sweep and serves its verbs, and calls no verb of the controller's; - // the lease is the one controller state it reads (clause 10's exemption). + // it reads no controller state, the lease neither (issue 1126). want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule} if !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes %v", m.Invokes) @@ -455,7 +455,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { t.Errorf("claims %+v, serves %v, the seat's verbs %v", m.Claims, verbs, contract) } for _, r := range m.Reads { - if strings.HasPrefix(r, "mesh-controller.") && r != LeaseRead { + if strings.HasPrefix(r, "mesh-controller.") { t.Errorf("reads the controller's %s", r) } } diff --git a/modules/artifact-store-tools/module.json b/modules/artifact-store-tools/module.json index f1ef7a43..eda3a100 100644 --- a/modules/artifact-store-tools/module.json +++ b/modules/artifact-store-tools/module.json @@ -27,8 +27,7 @@ "mesh-delivery-builder.builds", "mesh-delivery-builder.build-log", "mesh-delivery-indexer.manifests", - "node-engine.have", - "mesh-controller.lease" + "node-engine.have" ], "state": [ "findings" diff --git a/modules/mesh-healers/README.md b/modules/mesh-healers/README.md index d00cdb1b..2563ada9 100644 --- a/modules/mesh-healers/README.md +++ b/modules/mesh-healers/README.md @@ -15,15 +15,15 @@ Each act is begun by a create of its key in `attempts` before it is made and fin mid-act has spent it and a second instance cannot begin it again. The condition keeper writes the condition's `tried`, resolver and escalation from `attempts`; a healer writes no condition. -**Only once the controller's lease names no controller.** Until the write that hands the lease away, each act it would -take is written to `would`, never to `attempts`: it counts for no budget, settle or brake and reaches no condition. +**They act, and no lease turns it** (issue 1126): the controller ended by its unassignment. While it ran, each act a +healer would take was written to `would`, never to `attempts`; nothing writes `would` any more. Each key of `attempts` and `would` lives a week from its last write, its own time to live (the buckets are declared -`key-ttl`): nothing else deletes `would`, and once the lease is handed away nothing is written there. The healers' waits +`key-ttl`): nothing else deletes `would`. The healers' waits are keys of `wakes` whose time to live is the wait: `settle.` ends with a settle and judges the condition again, `fortnight` ends when S15's oldest counted act leaves the fortnight and counts again. The watch of `wakes` hears each expiry; no timer runs in the process. A wait still running is on the bus across a restart; one that expired while -no instance watched is judged at the start, once the lease is first read. +no instance watched is judged at the start. S15: it hears the seat mesh-hand-acts' `act-recorded` and watches the controller's hand-act log and writes `healer-wanted` for a cause repaired by hand twice within a fortnight; a drill and a person's decision by design never count. @@ -31,8 +31,8 @@ fortnight; a drill and a person's decision by design never count. ## What it keeps - `mesh-healers_attempts`: every act and escalation, each key kept a week. -- `mesh-healers_would`: the newest act it would take per healer and condition while the controller holds the lease, - each key kept a week. +- `mesh-healers_would`: the newest act it would take per healer and condition while the controller ran, each key + kept a week; nothing writes it any more. - `mesh-healers_findings`: the brake, its own blindness, and S15. - `mesh-healers_wakes`: the waits, each key living as long as it waits. @@ -41,6 +41,6 @@ fortnight; a drill and a person's decision by design never count. - `mesh-healers.healers`: the registry, the acts of the last days, the would-acts, the brake, whether it acts and why; `recount=true` counts the hand-act log again. - `mesh-healers.heal`: one act now on an open condition, by key and why. -- `mesh-healers.drill`: records a drill in the hand-act log of the seat `mesh-hand-acts` as `hand-act drill` (the - controller's `drill` while that seat has no holder and the controller holds its lease). S15 never counts a drill. +- `mesh-healers.drill`: records a drill in the hand-act log of the seat `mesh-hand-acts` as `hand-act drill`; with + no holder of that seat it is refused. S15 never counts a drill. - `healers_findings`, `healers_would`, `healers_wakes`: the reads of its buckets. diff --git a/modules/mesh-healers/cmd/mesh-healers/healers.go b/modules/mesh-healers/cmd/mesh-healers/healers.go index 31bc2606..778016a5 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers.go @@ -9,10 +9,9 @@ package main // healer's to say: the condition clears when its observer no longer sees it, and the condition keeper writes `tried`, // the resolver and the escalation into the condition from `attempts`. // -// **Only once the controller's lease names no controller** (ADR 0564 §8): until then each act it would take is -// written to the bucket `would`, never to `attempts`, so a would-act counts for no budget, settle or brake, reaches -// no `tried` and escalates nothing. `would` keeps each key a week (its bucket's age): nothing else deletes it, and once -// the lease is handed away nothing is written there again. +// **They act, and no lease turns it** (issue 1126): the controller ended by its unassignment, so the gate on its lease +// (ADR 0564 §8) is gone. The bucket `would` holds what they would have done while it ran, each key kept a week (its +// bucket's age); nothing writes there any more. import ( "encoding/json" @@ -42,8 +41,6 @@ const ( AttemptsState = health.AttemptsState WouldState = health.WouldState FindingsState = health.FindingsState - LeaseRead = buscompose.ControllerLease - LeaseKey = buscompose.ControllerLeaseKey ConditionsRead = "mesh-conditions.conditions" ActedEvent = health.EventHealerActed // ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays). @@ -115,8 +112,8 @@ type Condition struct { func (c Condition) Escalated() bool { return c.Resolver == "operator" && len(c.Tried) > 0 } // Attempt, Would and Acted are mesh-sdk's health layouts: an act begun in `attempts` (its key -// `..` is the healers' own), an act written to `would` while the lease names a -// controller (one key per healer and condition, `.`, its newest), and the body of healer-acted, +// `..` is the healers' own), an act written to `would` while the controller ran +// (one key per healer and condition, `.`, its newest), and the body of healer-acted, // epoch left out (ADR 0564 Limits 10). type ( Attempt = health.Attempt @@ -182,48 +179,14 @@ type Healers struct { Registry []Healer mu sync.Mutex - // lease is the controller's lease as last read: acting is on only while it is handed away. - acting bool - leaseWhy string // declined is why each condition was last passed over, said once. declined map[string]string // acts are the hand-act log as watched, for S15. acts map[string]HandAct } -// LeaseHeard records what the lease says: acting is on only while it names a holder that is no controller (ADR 0564 -// §8, Limits 8, as ADR 0561 §3's reader reads it). -func (h *Healers) LeaseHeard(raw []byte, found bool, readErr error) { - on, why := buscompose.LeaseHandedAway(raw, found, readErr) - h.mu.Lock() - flipped := on != h.acting || h.leaseWhy == "" - h.acting, h.leaseWhy = on, leaseWords(on, why) - h.mu.Unlock() - if flipped { - h.Logf("[%s] the healers %s: %s", Module, map[bool]string{true: "ACT", false: "act in shadow"}[on], h.leaseWhy) - } -} - -// leaseWords says the lease's state as the healers read it; the SDK's words speak of minting. -func leaseWords(on bool, why string) string { - if on { - return "the controller's lease is handed away: the healers act" - } - if strings.Contains(why, "cannot be read") || strings.Contains(why, "not readable") { - return "the controller's lease cannot be read, so it is taken to name a controller: every act is written as would" - } - return "the controller's lease names a controller (or none is written, so one may take it): every act is written as would" -} - -// Acting says whether the healers act, and why. -func (h *Healers) Acting() (bool, string) { - h.mu.Lock() - defer h.mu.Unlock() - if h.leaseWhy == "" { - return false, "the controller's lease has not been read yet: every act is written as would" - } - return h.acting, h.leaseWhy -} +// actingWhy is why the healers act: what the tool `healers` says. +const actingWhy = "the healers act: the controller ended by its unassignment, so no lease turns them (issue 1126)" // healerFor is the healer that acts here on a kind. func (h *Healers) healerFor(kind string) (Healer, bool) { @@ -235,7 +198,7 @@ func (h *Healers) healerFor(kind string) (Healer, bool) { return Healer{}, false } -// Heard is a condition the keeper raised or changed: judged, and acted on, written as would, or passed over. +// Heard is a condition the keeper raised or changed: judged, and acted on or passed over. func (h *Healers) Heard(body []byte) error { var c Condition if err := json.Unmarshal(body, &c); err != nil { @@ -265,13 +228,6 @@ func (h *Healers) Judge(c Condition, by string) (string, error) { } delete(h.declined, c.Key) now := h.Now() - if !h.acting { - w := Would{Healer: row.ID, Condition: c.Key, Kind: c.Kind, Act: row.Repair, At: now.UTC(), Why: h.whyNot()} - if err := h.Store.PutTTL(WouldState, row.ID+"."+c.Key, w, KeptFor); err != nil { - return "", fmt.Errorf("the would-act of %s on %s is not written: %w", row.ID, c.Key, err) - } - return fmt.Sprintf("healer %s would act on %s: %s", row.ID, c.Key, w.Why), nil - } all, err := h.attempts() if err != nil { h.blind(err) @@ -299,13 +255,6 @@ func (h *Healers) Judge(c Condition, by string) (string, error) { return h.act(row, c, budget, len(spent), by) } -func (h *Healers) whyNot() string { - if h.leaseWhy == "" { - return "the controller's lease has not been read yet" - } - return h.leaseWhy -} - // act is one healer's act: begun by a create in attempts, made, finished, and said. func (h *Healers) act(row Healer, c Condition, budget string, spent int, by string) (string, error) { begun := h.Now().UTC() @@ -379,7 +328,7 @@ func (h *Healers) tell(e Acted) { } } -// Rearm is the healers' start, once the lease is first read: a settle whose key expired while no instance watched has +// Rearm is the healers' start: a settle whose key expired while no instance watched has // no expiry left to hear, so each newest act whose settle ended within its window is judged at once. A settle still // running keeps its key on the bus and needs nothing. func (h *Healers) Rearm() { @@ -611,14 +560,14 @@ func (h *Healers) see(source string, found []Observation) { // --- H3 -------------------------------------------------------------------------------------------------------- // moduleOf is the module whose objects a condition names: a consumer of a module's own (`.`). The -// controller's own consumers are not a module's: the controller asserts them itself until its lease. +// controller's own consumers are not a module's: they go with the controller's leftovers (issue 1126). func moduleOf(c Condition) (string, string) { stream, consumer, ok := strings.Cut(c.Subject.ID, ".") if c.Kind != "consumer-lost" || !ok || stream == "" || consumer == "" { return "", "it names no consumer of a module whose objects the grants part asserts" } if consumer == buscompose.ControllerName || strings.HasPrefix(consumer, buscompose.ControllerName+"-") { - return "", "the consumer is the controller's own, which the controller asserts itself while it holds its lease" + return "", "the consumer is the controller's own, which the grants part does not assert" } return consumer, "" } diff --git a/modules/mesh-healers/cmd/mesh-healers/healers_test.go b/modules/mesh-healers/cmd/mesh-healers/healers_test.go index dcc42ae0..ddf57bd4 100644 --- a/modules/mesh-healers/cmd/mesh-healers/healers_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/healers_test.go @@ -154,10 +154,6 @@ func (w *world) fresh() { } } -func (w *world) handAway() { - w.h.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil) -} - // lost is a module's consumer the bus does not hold, as D6 raises it. func lost(module string) Condition { return Condition{Key: "bus.EVENTS." + module + ".missing", Kind: "consumer-lost", Severity: "warning", Resolver: "self", @@ -170,47 +166,21 @@ func body(c Condition) []byte { return raw } -// No act is made while the lease names a controller: each is written to would, never to attempts, so it counts for no -// budget or brake and nothing is asked or said; one is made from the write that hands the lease away (ADR 0564 §8). -func TestTheHealersActOnlyOnceTheLeaseNamesNoController(t *testing.T) { - cases := []struct { - name string - lease func(h *Healers) - acts bool - }{ - {"never read", func(*Healers) {}, false}, - {"absent", func(h *Healers) { h.LeaseHeard(nil, false, nil) }, false}, - {"unreadable", func(h *Healers) { h.LeaseHeard(nil, false, errors.New("timeout")) }, false}, - {"a controller holds it", func(h *Healers) { - h.LeaseHeard([]byte(`{"instance":"novox/1/2026-10-27","epoch":57}`), true, nil) - }, false}, - {"handed away", func(h *Healers) { h.LeaseHeard([]byte(`{"handed-to":"the operator"}`), true, nil) }, true}, +// 1126/A4/AC1: with no lease key read at all (the controller ended by its unassignment), a condition a healer covers is +// acted on: one attempt, finished, asked and said, and nothing written to would. +func TestTheHealersActWithNoLease(t *testing.T) { + w := newWorld() + if err := w.h.Heard(body(lost("records"))); err != nil { + t.Fatal(err) } - for _, tc := range cases { - w := newWorld() - tc.lease(w.h) - if err := w.h.Heard(body(lost("records"))); err != nil { - t.Fatalf("%s: %v", tc.name, err) - } - attempts, would := w.store.keys(AttemptsState), w.store.keys(WouldState) - if tc.acts { - if len(attempts) != 1 || len(would) != 0 || len(w.asked) != 1 || !strings.Contains(w.asked[0], `"module":"records"`) || - len(w.said) != 1 || w.said[0].Outcome != OutcomeActed { - t.Errorf("%s: attempts %v, would %v, asked %v, said %+v", tc.name, attempts, would, w.asked, w.said) - continue - } - a := w.store.attempt(t, attempts[0]) - if a.Outcome != OutcomeActed || a.Finished == nil || a.Healer != "H3" || a.Condition != "bus.EVENTS.records.missing" { - t.Errorf("%s: the attempt is %+v", tc.name, a) - } - continue - } - if len(attempts) != 0 || len(w.asked) != 0 || len(w.said) != 0 || !slices.Equal(would, []string{"H3.bus.EVENTS.records.missing"}) { - t.Errorf("%s: attempts %v, would %v, asked %v, said %v", tc.name, attempts, would, w.asked, w.said) - } - if _, braked, _ := w.store.Get(FindingsState, brakeKey); braked { - t.Errorf("%s: a would-act moved the brake", tc.name) - } + attempts, would := w.store.keys(AttemptsState), w.store.keys(WouldState) + if len(attempts) != 1 || len(would) != 0 || len(w.asked) != 1 || !strings.Contains(w.asked[0], `"module":"records"`) || + len(w.said) != 1 || w.said[0].Outcome != OutcomeActed { + t.Fatalf("attempts %v, would %v, asked %v, said %+v", attempts, would, w.asked, w.said) + } + a := w.store.attempt(t, attempts[0]) + if a.Outcome != OutcomeActed || a.Finished == nil || a.Healer != "H3" || a.Condition != "bus.EVENTS.records.missing" { + t.Errorf("the attempt is %+v", a) } } @@ -219,7 +189,6 @@ func TestTheHealersActOnlyOnceTheLeaseNamesNoController(t *testing.T) { // in an hour and lets go an hour after the last (ADR 0231 §21, ADR 0564 §7). func TestBudgetSettleEscalationAndBrake(t *testing.T) { w := newWorld() - w.handAway() c := lost("records") _ = w.store.Put(ConditionsRead, c.Key, c) settle := settlePrefix + c.Key @@ -247,7 +216,6 @@ func TestBudgetSettleEscalationAndBrake(t *testing.T) { switch s.how { case "restart": w.fresh() - w.handAway() w.h.Rearm() case "expire": w.h.WakeHeard(settle, OpExpire) @@ -277,28 +245,24 @@ func TestBudgetSettleEscalationAndBrake(t *testing.T) { } // A settle whose key expired while no instance watched is judged at the start. r := newWorld() - r.handAway() _ = r.store.Put(ConditionsRead, c.Key, c) if _, err := r.h.Judge(c, ""); err != nil { t.Fatal(err) } r.now = r.now.Add(7 * time.Minute) r.fresh() - r.handAway() r.h.Rearm() if len(r.said) != 2 || r.said[1].Outcome != OutcomeEscalated { t.Errorf("a settle that ended unheard is not judged at the start: %+v", r.said) } // A second instance cannot begin the act an attempt's key already holds. x := newWorld() - x.handAway() _ = x.store.CreateTTL(AttemptsState, attemptKey("H3", lost("mail").Key, x.now.UTC()), Attempt{}, KeptFor) if said, _ := x.h.Judge(lost("mail"), ""); !strings.Contains(said, "another instance") || len(x.asked) != 0 { t.Errorf("a second instance acted: %q, asked %v", said, x.asked) } // The brake: twelve acts in the hour stop every healer, said as an urgent finding. b := newWorld() - b.handAway() for i := 0; i < BrakeLimit; i++ { b.now = b.now.Add(time.Minute) _ = b.store.CreateTTL(AttemptsState, attemptKey("H3", "x", b.now), Attempt{Healer: "H3", Condition: "x", BudgetKey: "x", @@ -325,7 +289,6 @@ func TestBudgetSettleEscalationAndBrake(t *testing.T) { } // Blind to what they did, they act on nothing and say so. z := newWorld() - z.handAway() z.store.keysErr[AttemptsState] = errors.New("the bus is away") if _, err := z.h.Judge(lost("mail"), ""); err == nil || len(z.asked) != 0 { t.Errorf("blind, the healers acted: %v", err) @@ -350,7 +313,6 @@ func TestTheRegistryAndWhatH3Answers(t *testing.T) { } for _, tc := range cases { w := newWorld() - w.handAway() said, _ := w.h.Judge(tc.c, "") if acted := len(w.asked) == 1; acted != tc.holds { t.Errorf("%s: acted %v, want %v (%s)", tc.c.Key, acted, tc.holds, said) @@ -420,7 +382,6 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) { // answer every bucket. func TestTheVerbsAndReads(t *testing.T) { w := newWorld() - w.handAway() c := lost("records") _ = w.store.Put(ConditionsRead, c.Key, c) if _, err := w.h.Heal(c.Key, "", "person.jochen"); err == nil { @@ -444,28 +405,19 @@ func TestTheVerbsAndReads(t *testing.T) { if a["acting"] != true || len(a["acts"].([]Attempt)) != 1 || len(a["healers"].([]map[string]any)) != 3 { t.Errorf("healers answered %v", a) } - // drill records into mesh-hand-acts as a drill, and never into the controller's log, whoever holds the lease - // (ADR 0565 §11, issue 1955). + // drill records into mesh-hand-acts as a drill, and never into the controller's log (ADR 0565 §11, issue 1955). drills := []struct { - name string - seatGone bool - leaseAway bool - args map[string]any - log, err string + name string + seatGone bool + args map[string]any + log, err string }{ - {"into the seat", false, true, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""}, - {"into the seat under a controller's lease", false, false, map[string]any{"what": "stopped records", "why": "x"}, handacts.LogSeat, ""}, - {"no holder: refused, the controller's not asked", true, false, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"}, - {"no holder, lease away", true, true, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"}, - {"says nothing", false, true, map[string]any{}, "", "says what"}, + {"into the seat", false, map[string]any{"what": "stopped records", "why": "does it raise"}, handacts.LogSeat, ""}, + {"no holder: refused, the controller's not asked", true, map[string]any{"what": "stopped records", "why": "x"}, "", "nothing was recorded"}, + {"says nothing", false, map[string]any{}, "", "says what"}, } for _, d := range drills { x := newWorld() - if d.leaseAway { - x.handAway() - } else { - x.h.LeaseHeard([]byte(`{"instance":"novox/1","epoch":5}`), true, nil) - } var asked []string x.h.Ask = func(key string, body any) (json.RawMessage, error) { raw, _ := json.Marshal(body) diff --git a/modules/mesh-healers/cmd/mesh-healers/main.go b/modules/mesh-healers/cmd/mesh-healers/main.go index 90b482af..07c2980d 100644 --- a/modules/mesh-healers/cmd/mesh-healers/main.go +++ b/modules/mesh-healers/cmd/mesh-healers/main.go @@ -1,8 +1,8 @@ // mesh-healers: the holder of the seat mesh-healers (novox/hq ADR 0564 §7, §8). A Go bundle served by a runtime of its // own, on its own bus account (`runs-as`), so that its account alone writes its buckets: `attempts` (every act begun, -// made and finished, and every escalation, kept a week), `would` (what it would have done while the controller holds -// its lease, kept a week) and `findings` (the brake, its blindness, and S15). It hears the condition keeper's -// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and counts the seat's hand acts +// made and finished, and every escalation, kept a week), `would` (what it would have done while the controller ran, +// kept a week; nothing writes it any more) and `findings` (the brake, its blindness, and S15). It hears the condition keeper's +// condition-raised and condition-changed on a durable consumer, and counts the seat's hand acts // for S15. stdout is the MCP channel; what this module says, it says on stderr. package main @@ -11,7 +11,6 @@ import ( "errors" "fmt" "os" - "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -56,14 +55,9 @@ func main() { h := &Healers{Store: runtimeStore{}, Registry: Registry(), Now: time.Now, Logf: logf, Ask: func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) }, Publish: stdio.SeatPublish} - // The lease first: until it is read the healers write would-acts only. Once it is first read, a settle that ended - // while no instance watched is judged (Rearm); the waits still running are keys on the bus, heard on their expiry. - var started sync.Once - go stdio.State(LeaseRead).Follow(LeaseKey, func(c stdio.StateChange) error { - h.LeaseHeard(c.Value, c.Op == "put", nil) - started.Do(func() { go h.Rearm() }) - return nil - }) + // At start, a settle that ended while no instance watched is judged (Rearm); the waits still running are keys on + // the bus, heard on their expiry. + go h.Rearm() go stdio.State(WakesState).Follow("", func(c stdio.StateChange) error { h.WakeHeard(c.Key, c.Op) return nil diff --git a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go index f9ccac51..8a6bd2cc 100644 --- a/modules/mesh-healers/cmd/mesh-healers/manifest_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/manifest_test.go @@ -26,7 +26,7 @@ type manifest struct { // The manifest says what the code does: it claims the seat mesh-healers once for the mesh with its verbs (ADR 0564 §1, // §7), runs on its own account, says healer-acted, hears the keeper's raised and changed, asks only the grants part's -// assert, reads the lease, the keeper's conditions and the hand-act log, and keeps attempts and would a week each. +// assert, reads the keeper's conditions (never the controller's lease, issue 1126) and the hand-act log, and keeps attempts and would a week each. // Every state it keeps has a tool that reads it, and every act it takes by itself a tool that starts it (ADR 0564 §12). func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -50,7 +50,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { {"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil}, {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}}, {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts"}}, - {"reads", m.Reads, []string{LeaseRead, ConditionsRead}}, + {"reads", m.Reads, []string{ConditionsRead}}, } { if !slices.Equal(c.got, c.want) { t.Errorf("%s %v, want %v", c.name, c.got, c.want) @@ -74,7 +74,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { } readBy := map[string]string{AttemptsState: HealersTool, WouldState: WouldTool, FindingsState: FindingsTool, WakesState: WakesTool, "a settle's end": HealTool, - "an act on an event": HealTool, "S15's count": HealersTool + " recount=true", "the lease as read": HealersTool} + "an act on an event": HealTool, "S15's count": HealersTool + " recount=true"} tools := map[string]bool{} for _, tool := range Tools(&Healers{}) { tools[tool.Name] = true diff --git a/modules/mesh-healers/cmd/mesh-healers/tools.go b/modules/mesh-healers/cmd/mesh-healers/tools.go index c26a30f9..e5f1dc25 100644 --- a/modules/mesh-healers/cmd/mesh-healers/tools.go +++ b/modules/mesh-healers/cmd/mesh-healers/tools.go @@ -38,8 +38,8 @@ func Tools(h *Healers) []stdio.Tool { return []stdio.Tool{ {Name: HealersTool, Description: "The mesh's healers (novox/hq ADR 0564 §7, §8): the registry, every act and escalation of the last " + - "days (the bucket attempts), whether the mesh-wide brake holds, whether they act or only write what they " + - "would do while the controller holds its lease, and the would-acts. recount=true counts the hand-act log " + + "days (the bucket attempts), whether the mesh-wide brake holds, that they act (no lease turns them, " + + "issue 1126), and the would-acts kept from while the controller ran. recount=true counts the hand-act log " + "again for S15 (a cause repaired by hand twice in a fortnight).", Input: obj(map[string]any{ "days": map[string]any{"type": "number", "description": "how many days of acts back (default 7)"}, @@ -47,8 +47,7 @@ func Tools(h *Healers) []stdio.Tool { Run: func(a map[string]any) (any, error) { return h.Answer(intArg(a, "days", 7), boolArg(a, "recount")) }}, {Name: HealTool, Description: "One healer's act now on an open condition, by its key, within its budget and the brake, as an " + - "event would have it — or, while the controller holds its lease, the act it would take written as would. " + - "Says what was done.", + "event would have it. Says what was done.", Input: obj(map[string]any{ "key": map[string]any{"type": "string", "description": "the condition's key"}, "why": map[string]any{"type": "string", "description": "why, in a sentence"}}), @@ -76,8 +75,8 @@ func Tools(h *Healers) []stdio.Tool { Input: obj(map[string]any{"key": map[string]any{"type": "string"}}), Run: func(a map[string]any) (any, error) { return h.read(WakesState, strArg(a, "key")) }}, {Name: WouldTool, - Description: "What the healers would have done while the controller holds its lease (their bucket would, each " + - "key kept a week): one per healer and condition, its newest; or one whole by key.", + Description: "What the healers would have done while the controller ran (their bucket would, each " + + "key kept a week; nothing writes it any more): one per healer and condition, its newest; or one whole by key.", Input: obj(map[string]any{"key": map[string]any{"type": "string"}}), Run: func(a map[string]any) (any, error) { return h.read(WouldState, strArg(a, "key")) }}, } @@ -147,7 +146,6 @@ func (h *Healers) Answer(days int, recount bool) (any, error) { "budget": fmt.Sprintf("%d act(s) within %s, %s apart at least", r.Budget, r.Window, r.Settle), "then": r.Then, "acts-in": r.ActsIn, "from": r.From}) } - acting, why := h.Acting() would, err := h.read(WouldState, "") if err != nil { would = map[string]any{"unread": err.Error()} @@ -161,7 +159,7 @@ func (h *Healers) Answer(days int, recount bool) (any, error) { if recent == nil { recent = []Attempt{} } - return map[string]any{"healers": rows, "acts": recent, "days": days, "acting": acting, "why": why, "would": would, + return map[string]any{"healers": rows, "acts": recent, "days": days, "acting": true, "why": actingWhy, "would": would, "brake": map[string]any{"holds": braked, "said": said, "limit": BrakeLimit, "window": BrakeWindow.String()}, "declined": declined, "note": "a heal is never a hand act; whether it repaired anything is the condition's clearing to say"}, nil diff --git a/modules/mesh-healers/cmd/mesh-healers/whole_integration_test.go b/modules/mesh-healers/cmd/mesh-healers/whole_integration_test.go index 081e878c..be4abc4e 100644 --- a/modules/mesh-healers/cmd/mesh-healers/whole_integration_test.go +++ b/modules/mesh-healers/cmd/mesh-healers/whole_integration_test.go @@ -5,9 +5,9 @@ package main // mesh-healers whole against a real bus (novox/hq ADR 0564 §7, §8; issue 1952 H3): the built binary, played over its // real stdin and stdout as the node's tool runtime plays it (mesh/state.* and watches answered from a real nats-server's // key-value buckets, the keeper's events handed over as the runtime hands them, the grants part's assert answered). An -// induced failure, a module's consumer gone, is only written as would while the controller holds its lease; from the -// write that hands the lease away a healer acts, its attempt on the bus and healer-acted said; past twelve acts in the -// hour the brake stops every healer, said as an urgent finding on the bus. +// induced failure, a module's consumer gone, is acted on with no lease key anywhere (issue 1126), its attempt on the bus +// and healer-acted said, nothing written as would; past twelve acts in the hour the brake stops every healer, said as an +// urgent finding on the bus. import ( "bufio" @@ -320,7 +320,7 @@ func raised(key, module string) []byte { return raw } -// 1952/H3: an induced failure; the healer acts only from the lease write, and brakes. +// 1952/H3, 1126/A4: an induced failure with no lease key; the healer acts, and brakes. func TestTheHealersWholeActOnTheBusAndBrake(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -341,7 +341,7 @@ func TestTheHealersWholeActOnTheBusAndBrake(t *testing.T) { t.Fatal(err) } for _, b := range []string{Module + "_" + AttemptsState, Module + "_" + WouldState, Module + "_" + FindingsState, Module + "_" + WakesState, - "mesh-controller_lease", "mesh-conditions_conditions", "mesh-controller_hand-acts"} { + "mesh-conditions_conditions", "mesh-controller_hand-acts"} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { t.Fatal(err) } @@ -357,10 +357,6 @@ func TestTheHealersWholeActOnTheBusAndBrake(t *testing.T) { k, _ := bucket(name).Keys(ctx) return k } - lease := bucket("mesh-controller_lease") - if _, err := lease.Put(ctx, LeaseKey, []byte(`{"instance":"novox/1/2026-10-27","epoch":57}`)); err != nil { - t.Fatal(err) - } acted, err := nc.SubscribeSync("mesh.seat." + Seat + ".event." + ActedEvent) if err != nil { t.Fatal(err) @@ -385,24 +381,11 @@ func TestTheHealersWholeActOnTheBusAndBrake(t *testing.T) { _ = json.Unmarshal(raw, &a) return a } - eventually(t, "the lease read as a controller's", func() bool { - a := healers() - return a != nil && strings.Contains(fmt.Sprint(a["why"]), "names a controller") - }) + eventually(t, "the healers acting", func() bool { return healers()["acting"] == true }) // The runtime's consumer holds an event until the bundle subscribes: delivered once it hears them. eventually(t, "the healers hearing the keeper's conditions", func() bool { return p.hears(RaisedEvent) && p.hears(ChangedEvent) }) - // The controller holds the lease: the induced failure is written as would, never acted on. - p.deliver(RaisedEvent, raised("bus.EVENTS.records.missing", "records")) - eventually(t, "the would-act written", func() bool { return len(keys(Module+"_"+WouldState)) == 1 }) - if k := keys(Module + "_" + AttemptsState); len(k) != 0 { - t.Fatalf("acted under the controller's lease: %v", k) - } - // The write that hands the lease away: the same failure is acted on. - if _, err := lease.Put(ctx, LeaseKey, []byte(`{"handed-to":"the operator's hand-over"}`)); err != nil { - t.Fatal(err) - } - eventually(t, "the healers acting", func() bool { return healers()["acting"] == true }) + // No lease key: the induced failure is acted on. p.deliver(RaisedEvent, raised("bus.EVENTS.records.missing", "records")) eventually(t, "the act begun and finished on the bus", func() bool { k := keys(Module + "_" + AttemptsState) @@ -412,6 +395,9 @@ func TestTheHealersWholeActOnTheBusAndBrake(t *testing.T) { e, err := bucket(Module+"_"+AttemptsState).Get(ctx, k[0]) return err == nil && strings.Contains(string(e.Value()), `"outcome":"acted"`) }) + if k := keys(Module + "_" + WouldState); len(k) != 0 { + t.Errorf("written as would: %v", k) + } msg, err := acted.NextMsg(10 * time.Second) if err != nil || !strings.Contains(string(msg.Data), `"healer":"H3"`) { t.Fatalf("healer-acted was not said: %v", err) diff --git a/modules/mesh-healers/module.json b/modules/mesh-healers/module.json index fc05d31a..041b1612 100644 --- a/modules/mesh-healers/module.json +++ b/modules/mesh-healers/module.json @@ -24,7 +24,6 @@ "seat:mesh-hand-acts.hand-acts" ], "reads": [ - "mesh-controller.lease", "mesh-conditions.conditions" ], "state": [