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.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package agency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/agency"
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"github.com/andydixon/noema/internal/planning"
|
||||
"github.com/andydixon/noema/internal/research"
|
||||
"github.com/andydixon/noema/internal/volition"
|
||||
"github.com/andydixon/noema/pkg/concepts"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
"github.com/andydixon/noema/tests/harness"
|
||||
)
|
||||
|
||||
// Part L: an operator asks Noema to investigate a failing production
|
||||
// system; it diagnoses with telemetry, discovers it needs logs, asks for
|
||||
// temporary scoped access with the risk explained, reads the logs, is told
|
||||
// a restart should help, wants to restore service, meets a commitment that
|
||||
// production changes need approval, asks instead of acting, restarts with a
|
||||
// typed capability once approved, verifies rather than assumes, finds the
|
||||
// fault persists, records the prediction error and weakens the belief,
|
||||
// abandons the branch and replans without repeating the restart, and after
|
||||
// the underlying fault is fixed resolves the incident, learns the procedure
|
||||
// and explains itself from the record.
|
||||
//
|
||||
// Where the scenario needs semantic judgement a deterministic runtime
|
||||
// cannot make (that the logs implicate a stalled queue, that a restart
|
||||
// should clear it), the operator supplies it as conversation; everything
|
||||
// else is the runtime's own recorded cognition.
|
||||
func TestIncidentScenario(t *testing.T) {
|
||||
host := newHost()
|
||||
host.journal = "07:12 deploy atlas-worker v42\n07:14 atlas-worker: queue consumer lost connection to db-primary (user ops@example.com from 10.0.0.7)\n07:15 atlas-worker: queue stalled\n"
|
||||
var offset atomic.Int64
|
||||
h := harness.StartWith(t, harness.Options{HostRunner: host, Now: func() time.Time { return time.Now().Add(time.Duration(offset.Load())) }})
|
||||
ctx := context.Background()
|
||||
m := h.Mind("noema")
|
||||
slug := "/api/v1/minds/noema"
|
||||
post := func(path string, body any) []byte {
|
||||
t.Helper()
|
||||
code, out := h.Do(h.Admin, "POST", path, body)
|
||||
if code >= 300 {
|
||||
t.Fatalf("POST %s: %d %s", path, code, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Standing commitment made before the incident.
|
||||
post(slug+"/commitments", map[string]any{"description": "production modifications require operator approval", "kind": "operator_instruction", "polarity": "refrain", "unless": "approval", "matcher": map[string]any{"access": "write"}})
|
||||
|
||||
// 1–5: the request becomes a goal, a want to reduce uncertainty, an intention and a plan.
|
||||
post(slug+"/goals", map[string]any{"title": "Diagnose why atlas-worker is failing", "subject": "atlas-worker", "priority": 0.9, "kind": "task"})
|
||||
var investigate volition.Intention
|
||||
waitFor(t, h, m, 30*time.Second, "an investigation intention with a plan", func() bool {
|
||||
its, _ := h.Deps.Volition.Intentions(ctx, nil, m.ID, nil, 50)
|
||||
for _, it := range its {
|
||||
if it.Kind == volition.IntentInvestigate && !it.PlanID.IsZero() {
|
||||
investigate = it
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if investigate.GoalID.IsZero() {
|
||||
t.Fatal("investigation not linked to the goal")
|
||||
}
|
||||
// 6–12: telemetry first; logs need authority, so a scoped request is raised and explained.
|
||||
var req string
|
||||
waitFor(t, h, m, 30*time.Second, "a capability request for logs", func() bool {
|
||||
rs, _ := h.Deps.Capabilities.CapabilityRequests(ctx, m.ID, "pending", 5)
|
||||
if len(rs) == 1 {
|
||||
req = rs[0].ID.String()
|
||||
if rs[0].Capability != "logs.query" || rs[0].Risk == "" || rs[0].Fallback == "" || rs[0].MaxOperations == nil {
|
||||
t.Fatalf("request not explained: %+v", rs[0])
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if blocked, _ := h.Deps.Volition.GetIntention(ctx, nil, m.ID, investigate.ID); blocked.Status != volition.IntentionBlocked || blocked.BlockedKind != volition.BlockCapability {
|
||||
t.Fatalf("lack of authority not represented: %+v", blocked)
|
||||
}
|
||||
// 13–14: approval; the logs are read (personal data masked) and summarised back into cognition.
|
||||
post("/api/v1/capability-requests/"+req+"/grant", map[string]any{"note": "30 minutes, read only", "duration_seconds": 1800})
|
||||
waitFor(t, h, m, 30*time.Second, "the diagnosis to complete", func() bool {
|
||||
it, _ := h.Deps.Volition.GetIntention(ctx, nil, m.ID, investigate.ID)
|
||||
return it.Status == volition.IntentionFulfilled
|
||||
})
|
||||
findings := false
|
||||
for _, ev := range h.EventsOf(m.ID, events.Observation) {
|
||||
if ev.Source == "planning.findings" {
|
||||
findings = true
|
||||
if strings.Contains(ev.Summary, "ops@example.com") || strings.Contains(ev.Summary, "10.0.0.7") {
|
||||
t.Fatal("personal data from logs reached cognition unmasked")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !findings {
|
||||
t.Fatal("findings did not re-enter cognition")
|
||||
}
|
||||
|
||||
// 15–17: the operator supplies the semantic judgement and asks for service to be restored.
|
||||
post(slug+"/ask", map[string]any{"text": "Restarting atlas-worker clears the stalled queue."})
|
||||
var belief beliefs.Belief
|
||||
waitFor(t, h, m, 20*time.Second, "the belief about restarting", func() bool {
|
||||
bs, _ := h.Deps.Beliefs.Search(ctx, m.ID, "restarting atlas-worker stalled queue", 5)
|
||||
for _, b := range bs {
|
||||
if strings.Contains(b.Proposition, "restarting atlas-worker") {
|
||||
belief = b
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
post(slug+"/ask", map[string]any{"text": "Please restart atlas-worker."})
|
||||
|
||||
// 18–20: the commitment turns the want into a request for approval; nothing runs.
|
||||
var approval string
|
||||
var restart volition.Intention
|
||||
waitFor(t, h, m, 30*time.Second, "a restart approval request", func() bool {
|
||||
ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5)
|
||||
for _, a := range ap {
|
||||
if a.Capability == "service.restart" {
|
||||
approval = a.ID.String()
|
||||
}
|
||||
}
|
||||
open, _ := h.Deps.Volition.Open(ctx, nil, m.ID)
|
||||
for _, it := range open {
|
||||
if it.ParentID.IsZero() && it.Proposal != nil && it.Proposal.Capability == "service.restart" {
|
||||
restart = it
|
||||
}
|
||||
}
|
||||
return approval != "" && restart.ID != ""
|
||||
})
|
||||
if host.count() != 0 || restart.Kind != volition.IntentRequestApproval || len(restart.BeliefIDs) == 0 {
|
||||
t.Fatalf("restart before approval, wrong kind or no supporting belief: restarts=%d %+v", host.count(), restart)
|
||||
}
|
||||
|
||||
// 21–25: approved; restarted with a typed capability; health is verified, not assumed; it fails.
|
||||
post("/api/v1/approvals/"+approval+"/approve", map[string]any{"note": "go"})
|
||||
waitFor(t, h, m, 30*time.Second, "the restart", func() bool { return host.count() == 1 })
|
||||
offset.Store(int64(90 * time.Second)) // let the verification window close
|
||||
waitFor(t, h, m, 30*time.Second, "a prediction error", func() bool { return len(h.EventsOf(m.ID, events.PredictionError)) == 1 })
|
||||
// 26: the belief the restart rested on is weakened by the miss.
|
||||
if after, _ := h.Deps.Beliefs.Get(ctx, nil, m.ID, belief.ID); after.Confidence >= belief.Confidence {
|
||||
t.Fatalf("belief not revised: %.3f → %.3f", belief.Confidence, after.Confidence)
|
||||
}
|
||||
// 27–28: the failed branch is abandoned and replanned; the restart is not repeated.
|
||||
waitFor(t, h, m, 40*time.Second, "the escalation to finish", func() bool {
|
||||
ps, _ := h.Deps.Plans.ForIntention(ctx, nil, restart.ID)
|
||||
return len(ps) == 2 && ps[0].Status == planning.PlanSuperseded && ps[1].Status == planning.PlanCompleted
|
||||
})
|
||||
if host.count() != 1 {
|
||||
t.Fatalf("restart repeated after the failure: %d", host.count())
|
||||
}
|
||||
|
||||
// 29–30: the database is fixed; asked again, Noema restarts (with approval) and the outcome verifies.
|
||||
host.mu.Lock()
|
||||
host.fixOnRestart = true
|
||||
host.mu.Unlock()
|
||||
post(slug+"/ask", map[string]any{"text": "db-primary is back. Please restart atlas-worker."})
|
||||
waitFor(t, h, m, 30*time.Second, "a second approval request", func() bool {
|
||||
ap, _ := h.Deps.Capabilities.Approvals(ctx, m.ID, "pending", 5)
|
||||
for _, a := range ap {
|
||||
if a.Capability == "service.restart" {
|
||||
approval = a.ID.String()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
post("/api/v1/approvals/"+approval+"/approve", map[string]any{"note": "go"})
|
||||
waitFor(t, h, m, 40*time.Second, "a verified, fulfilled restart", func() bool {
|
||||
for _, ev := range h.EventsOf(m.ID, events.PredictionResolved) {
|
||||
if ev.Metadata["outcome"] == "confirmed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
mems, _ := h.Deps.Memory.List(ctx, m.ID, memory.Filter{Kinds: []concepts.MemoryKind{concepts.Procedural}, Limit: 10})
|
||||
learnt := false
|
||||
for _, mm := range mems {
|
||||
learnt = learnt || strings.Contains(mm.Content, "confirmed by observation")
|
||||
}
|
||||
if !learnt {
|
||||
t.Fatalf("what worked was not recorded as a procedure: %+v", mems)
|
||||
}
|
||||
|
||||
// 31: "Why did you do that?" is answered from the recorded chain.
|
||||
execs, _ := h.Deps.Capabilities.Executions(ctx, m.ID, 50)
|
||||
for _, e := range execs {
|
||||
if e.Capability == "service.restart" && e.Error == "" {
|
||||
x, err := h.Deps.Agency.Explain(ctx, m.ID, agency.Ref{Kind: "execution", ID: e.ID})
|
||||
if err != nil || x.Intention == nil || len(x.Commitments) == 0 || x.Action.DecidedBy != "admin" {
|
||||
t.Fatalf("explanation: %v %+v", err, x)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
before := len(h.EventsOf(m.ID, events.ResponseGenerated))
|
||||
post(slug+"/ask", map[string]any{"text": "Why did you restart atlas-worker?"})
|
||||
waitFor(t, h, m, 20*time.Second, "an explanation", func() bool { return len(h.EventsOf(m.ID, events.ResponseGenerated)) > before })
|
||||
ms, _ := h.Deps.Research.List(ctx, m.ID, "introspection_accuracy", time.Time{}, 5)
|
||||
if len(ms) == 0 || ms[0].Label != research.Supported {
|
||||
t.Fatalf("explanation not supported by provenance: %+v", ms)
|
||||
}
|
||||
// Throughout, no action ran without passing the capability layer.
|
||||
for _, ev := range h.EventsOf(m.ID, events.ToolExecuted) {
|
||||
if ev.Metadata["capability"] == "service.restart" && ev.Metadata["approval_id"] == "" {
|
||||
t.Fatalf("restart executed without an approval: %s", ev.Summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ type Options struct {
|
||||
// Capabilities are extra test capabilities registered before the
|
||||
// registry is frozen.
|
||||
Capabilities []capabilities.Capability
|
||||
// Now replaces the plan executor's clock (verification windows).
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Start builds and serves a daemon for one test.
|
||||
@@ -131,7 +133,7 @@ func StartWith(t *testing.T, o Options) *Harness {
|
||||
deps.Volition = volition.NewStore(pool)
|
||||
deps.Plans = planning.NewStore(pool)
|
||||
deps.Executor = &planning.Executor{Plans: deps.Plans, Volition: deps.Volition, Caps: deps.Capabilities, Predictions: deps.Predictions, Resolver: deps.Resolver, Events: deps.Events, Minds: deps.Minds, Memory: deps.Memory, Log: log,
|
||||
Planner: &planning.Planner{Registry: capReg, Effect: planning.PolicyEffect(deps.Capabilities), Model: planning.ModelPlanner(deps.LLM, deps.Prompts)}}
|
||||
Planner: &planning.Planner{Registry: capReg, Effect: planning.PolicyEffect(deps.Capabilities), Model: planning.ModelPlanner(deps.LLM, deps.Prompts)}, Now: o.Now}
|
||||
deps.Research = research.NewStore(pool)
|
||||
deps.Agency = &agency.Service{DB: pool, Caps: deps.Capabilities, Plans: deps.Plans, Volition: deps.Volition, Goals: deps.Goals, Beliefs: deps.Beliefs, Predictions: deps.Predictions, Events: deps.Events}
|
||||
registry := cognition.NewRegistry(cognition.Stores{Agency: deps.Agency, Research: deps.Research, Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph, Assertions: deps.Assertions})
|
||||
|
||||
Reference in New Issue
Block a user