diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index 2b840f4..2a5b881 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -24,6 +24,14 @@ type pendingHexisExec struct { entityID string displayName string expiry time.Time + + // correlationID — the id the proposing turn minted for this action. A + // confirm arrives on a later turn with a context of its own, so without + // carrying it here the execution recorded a fresh id and no causation at + // all, and the resolve, the discovery and the thing they authorised sat in + // the trace as unrelated calls. The contract mints one id per action, and + // the action began when she asked. + correlationID string } // pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become @@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve return hx != nil && !h.now().After(hx.expiry) }, yes: func() string { - return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) + execCtx := ctx + if hx.correlationID != "" { + execCtx = withCorrelationID(execCtx, hx.correlationID) + } + return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) }, no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) }, }, diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 3f6f851..ea6ab07 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, }) var parts []string + var spoken []string for _, item := range items { title, _ := item["title"].(string) if title == "" { continue } parts = append(parts, title) - surfaceSpoken(ctx, px, item) + if id := surfaceSpoken(ctx, px, item); id != "" { + spoken = append(spoken, id) + } } + // The scoped digest is a list she read out, so it replaces the positional + // memory exactly as the unscoped one does. It used to surface these items + // and remember none of them, which left the previous digest live: "отметь + // второй как сделанное" then indexed into a list he had not just heard and + // transitioned somebody else's item (docs/ecosystem.md — a wrong guess here + // transitions the wrong item). + h.rememberSurfaced(spoken) if known := h.localFactsForEntity(ctx, entityID); known != "" { parts = append(parts, known) } @@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio entityID: entityID, displayName: displayName, expiry: h.now().Add(confirmTTL), + // This action's id, so the execution the confirm authorises is + // joined to the resolve and the discovery that proposed it. + correlationID: correlationIDFromCtx(ctx), } h.mu.Unlock() h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go index 8b8ce5d..42a3c7e 100644 --- a/cmd/mavend/ecosystem_harness_test.go +++ b/cmd/mavend/ecosystem_harness_test.go @@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) { t.Fatalf("expected success once nexus recovers, got %q", reply) } } + +// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list +// she read out, so a positional follow-up must land on one of ITS items. It +// surfaced them and remembered none, which left the previous digest live and +// sent "отметь второй" at somebody else's item. +func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) { + ctx := context.Background() + nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) + scoped := fixturePraxisAttentionScoped("ent_muzick", + map[string]any{"id": "item_scoped_1", "title": "indexer wedged"}) + praxis := newFakePraxis(t, scoped) + h := ecoHandler(t, nexus, praxis, nil) + + // A digest from an earlier turn, still the positional memory. + h.rememberSurfaced([]string{"item_stale"}) + + reply := h.handlePraxisAct(ctx, router.Decision{ + Intent: router.IntentAct, + Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"}, + }) + if !strings.Contains(reply, "indexer wedged") { + t.Fatalf("expected the scoped item to be read out, got %q", reply) + } + + h.mu.Lock() + surfaced := append([]string(nil), h.surfacedItems...) + h.mu.Unlock() + if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" { + t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced) + } + + // The follow-up resolves against what he just heard, not the stale list. + if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" { + t.Fatal("positional follow-up should have been claimed by praxis") + } + var body string + for _, r := range praxis.Requests() { + if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" { + body = string(r.Body) + } + } + if !strings.Contains(body, "item_scoped_1") { + t.Fatalf("resolve must transition the item she read out, posted %q", body) + } + if strings.Contains(body, "item_stale") { + t.Fatal("resolve transitioned an item from a previous digest") + } +} + +// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a +// later turn with a context of its own. The contract mints one id per action, +// so the execution it authorises must still be joinable to the resolve and the +// discovery that proposed it — it recorded a fresh id and no causation at all. +func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) { + ctx := context.Background() + nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) + caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false}) + hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded")) + h := ecoHandler(t, nexus, nil, hexis) + + if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") { + t.Fatalf("mutating capability must ask for confirmation, got %q", reply) + } + resolve := findTrace(t, h, "nexus", "resolve") + if resolve == nil || resolve.CorrelationID == "" { + t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve) + } + + if _, handled := h.resolveConfirm(ctx, "да"); !handled { + t.Fatal("confirm should have been claimed") + } + exec := findTrace(t, h, "hexis", "execute") + if exec == nil { + t.Fatal("expected a hexis execute trace") + } + if exec.CausationID != resolve.CorrelationID { + t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q", + exec.CausationID, resolve.CorrelationID) + } +}