the scoped digest remembers what it read out, and a confirm keeps its action's id (V-623)
Two ecosystem defects, both of the shape where a call reports done and nothing of the sort happened. entityAttentionCapability surfaced every item it spoke and remembered none of them, so the previous digest stayed the positional memory. A follow-up "отметь второй как сделанное" then indexed into a list he had not just heard and transitioned somebody else's item, which is the exact harm the position resolver exists to prevent. A parked Hexis confirm did not carry the correlation id of the action that proposed it. The confirm lands on a later turn with a context of its own, so the execution recorded a fresh id and an empty causation: the resolve, the discovery and the thing they authorised sat in the trace as three unrelated calls. The contract mints one id per action.
This commit is contained in:
+13
-1
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
|
|||||||
entityID string
|
entityID string
|
||||||
displayName string
|
displayName string
|
||||||
expiry time.Time
|
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
|
// 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)
|
return hx != nil && !h.now().After(hx.expiry)
|
||||||
},
|
},
|
||||||
yes: func() string {
|
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) },
|
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
})
|
})
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
var spoken []string
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
title, _ := item["title"].(string)
|
title, _ := item["title"].(string)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, title)
|
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 != "" {
|
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||||
parts = append(parts, known)
|
parts = append(parts, known)
|
||||||
}
|
}
|
||||||
@@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
entityID: entityID,
|
entityID: entityID,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
expiry: h.now().Add(confirmTTL),
|
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.mu.Unlock()
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||||
|
|||||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
|||||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user