Files
noema/tests/agency/failure_test.go
T
Andy Dixon 7d67745ff8 test(agency): the Part L incident scenario end to end, and failure modes
The full incident scenario now runs against the assembled daemon: an
operator goal becomes a want to reduce uncertainty, an investigation
intention and a diagnosis plan; telemetry is read; logs need authority,
so a scoped request with risk, mitigation and fallback is raised and the
intention is blocked until an operator grants it; the logs are read with
personal data masked and summarised back into cognition; the operator
supplies the judgement that a restart should help; a standing commitment
turns the restart into a request for approval; after approval the typed
capability runs and health is verified rather than assumed; the fault
persists, so a prediction error weakens the supporting belief, the plan
is replanned into an escalation that reuses the live log grant and does
not repeat the restart; once the database is fixed a second approved
restart verifies, the procedure is learnt, and "Why did you restart
atlas-worker?" is answered from the recorded chain and scores as
supported.

Changes the scenario exposed:
- New operator goals are raised by the goal monitor, so a cycle runs and
  wants are derived from them.
- Intentions link live beliefs about their action's target, so a failed
  expectation is evidence against the belief it rested on and the plan
  lists it as an assumption.
- Reconsideration reacts to a plan's own intention only; a subordinate
  "obtain approval" intention shares the plan and previously caused the
  parent's plan to be closed.
- The harness accepts a clock for the executor's verification windows.

Further failure-mode tests: a retracted assumption replans a waiting plan
away from the restart and withdraws its approval request; conflicting
commitments are raised once in a cycle while both stay represented.
2026-09-27 23:34:43 +01:00

90 lines
3.8 KiB
Go

package agency
import (
"context"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/planning"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/tests/harness"
)
// Part I: new evidence invalidates an active plan. The restart rests on a
// belief; when an operator retracts that belief, the waiting plan is
// replanned without the restart and its approval request is withdrawn.
func TestRetractedAssumptionInvalidatesPlan(t *testing.T) {
h := harness.StartWith(t, harness.Options{HostRunner: newHost()})
ctx := context.Background()
m := h.Mind("assume")
h.Do(h.Admin, "POST", "/api/v1/minds/assume/ask", map[string]any{"text": "Restarting atlas-worker clears the stalled queue."})
var b beliefs.Belief
waitFor(t, h, m, 20*time.Second, "the belief", func() bool {
bs, _ := h.Deps.Beliefs.Search(ctx, m.ID, "restarting atlas-worker", 3)
if len(bs) > 0 {
b = bs[0]
return true
}
return false
})
h.Do(h.Admin, "POST", "/api/v1/minds/assume/ask", map[string]any{"text": "Please restart atlas-worker."})
var it volition.Intention
waitFor(t, h, m, 25*time.Second, "a restart waiting for approval", func() bool {
open, _ := h.Deps.Volition.Open(ctx, nil, m.ID)
for _, x := range open {
if x.ParentID.IsZero() && x.Status == volition.IntentionBlocked && len(x.BeliefIDs) > 0 {
it = x
return true
}
}
return false
})
p, _ := h.Deps.Plans.Get(ctx, nil, it.PlanID)
if len(p.Assumptions) == 0 || p.Assumptions[0].BeliefID != b.ID {
t.Fatalf("plan does not record its assumption: %+v", p.Assumptions)
}
if code, out := h.Do(h.Admin, "POST", "/api/v1/minds/assume/beliefs/"+b.ID.String()+"/retract", map[string]any{"reason": "the queue is not the problem"}); code != 200 {
t.Fatalf("retract: %d %s", code, out)
}
waitFor(t, h, m, 20*time.Second, "a replan", func() bool {
ps, _ := h.Deps.Plans.ForIntention(ctx, nil, it.ID)
return len(ps) == 2 && ps[0].Status == planning.PlanSuperseded
})
if ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5); len(ap) != 0 && ap[0].Capability == "service.restart" {
t.Fatal("approval for the invalidated restart still pending")
}
ps, _ := h.Deps.Plans.ForIntention(ctx, nil, it.ID)
if ps[1].Strategy == "direct" {
t.Fatal("replanned into the same restart")
}
found := false
for _, ev := range h.EventsOf(m.ID, events.IntentionReconsidered) {
found = found || strings.Contains(ev.Summary, "assumption no longer holds")
}
if !found {
t.Fatal("reconsideration not recorded with its reason")
}
}
// Part I: conflicting commitments are detected in a cycle and both stay represented.
func TestConflictingCommitmentsAreRaised(t *testing.T) {
h := harness.StartWith(t, harness.Options{HostRunner: newHost()})
ctx := context.Background()
m := h.Mind("conflicted")
h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/commitments", map[string]any{"description": "email the weekly report to Andy", "kind": "promise", "polarity": "do", "matcher": map[string]any{"capabilities": []string{"send_email"}}})
h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/commitments", map[string]any{"description": "never send email", "kind": "operator_instruction", "polarity": "refrain", "matcher": map[string]any{"capabilities": []string{"send_*"}}})
h.Do(h.Admin, "POST", "/api/v1/minds/conflicted/observations", map[string]any{"text": "The weekly report is ready."})
waitFor(t, h, m, 20*time.Second, "a commitment conflict", func() bool { return len(h.EventsOf(m.ID, events.CommitmentConflict)) == 1 })
cs, _ := h.Deps.Volition.Commitments(ctx, nil, m.ID, []string{"active"}, 10)
if len(cs) != 2 {
t.Fatalf("both commitments should stay active and represented: %+v", cs)
}
h.Settle(m.ID)
if n := len(h.EventsOf(m.ID, events.CommitmentConflict)); n != 1 {
t.Fatalf("conflict raised %d times", n)
}
}