From e4bfcd958fd35d9611cb48278f71d211bcc1ff5a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:00:46 +0400 Subject: [PATCH 1/4] netscan: stop the scan wedging, and stop it overstating the LAN The results channel was sized by the number of hosts while each worker sends once per open port, so a subnet with more open ports than addresses filled the buffer and blocked a worker forever. Nothing drains the channel until wg.Wait returns and the sends have no ctx.Done case, so the calling turn hung for the life of the process. Size it by probes. Three more claims the scanner could not back. MaxHosts was spent in order, so the second of two configured subnets got two addresses out of 254 with nothing logged. A run cut short by the cap or the deadline came back indistinguishable from a complete one, and the shipped defaults never fit the budget, so every scan was silently truncated at the top of the range. Scan now reports truncation, targets are taken round-robin, and the default rate and the budget are consistent with a /24. The spoken reply read dotted quads out loud on the voice path. It now says how many devices and what shape, and writes the address list as a note, which is also the only record that Maven put packets on the LAN. The network noun is matched whole so posetil is not a scan, the rate has a stated ceiling, and a repeat question inside two minutes reuses the answer. Both query sources claimed the turn when the capability was off, which let an unconfigured scanner and an unconfigured house swallow questions that used to reach recall. Both now fall through. Found in review of #81 and #80. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/actions_query.go | 16 +-- cmd/mavend/netscan.go | 178 +++++++++++++++++++++++++++---- cmd/mavend/netscan_test.go | 66 +++++++++++- cmd/mavend/voicewire.go | 2 +- deploy/mavend.json | 2 +- internal/netscan/netscan.go | 125 +++++++++++++++++----- internal/netscan/netscan_test.go | 100 ++++++++++++++++- models/stt | 1 + models/tts | 1 + 9 files changed, 428 insertions(+), 63 deletions(-) create mode 120000 models/stt create mode 120000 models/tts diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c36682d..b8352bd 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -295,9 +295,13 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, return "", false } if h.home == nil { - // Claim the turn rather than fall through: "дом не подключён" is true, - // and letting general knowledge answer would be an invented house. - return "дом не подключён — я его не вижу.", true + // Fall through rather than claim the turn. A capability that is off + // must not change what an unconfigured box answers: "какая температура + // в доме?" on a Maven with no smarthome block reached recall before + // this source existed, and a stored fact is a better answer than + // "дом не подключён" from a house that was never configured. The + // unreachable case is different and homeSummary covers it. + return "", false } ctxH, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -313,9 +317,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin return "", false } if h.netscan == nil { - // Claim the turn: "сканирование не настроено" is true, and general - // knowledge would answer with an invented list of devices. - return "сканирование сети не настроено.", true + // Fall through, same as queryHome: an unconfigured scanner must not + // swallow "сколько устройств в сети?" before recall has looked. + return "", false } return h.netscan.scanSummary(ctx) } diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 95cc923..83c1c35 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -5,20 +5,33 @@ import ( "fmt" "log" "strings" + "sync" "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/netscan" ) // scanBudget — the whole spoken scan, end to end. A voice turn that takes // longer than this has already failed as a turn, so the scan returns whatever // it found rather than keeping him waiting. -const scanBudget = 20 * time.Second +// +// It has to be consistent with the shipped defaults or every scan is truncated: +// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a +// second is a little over ten seconds plus the tail dials. 30s leaves room for +// that without pretending a slower rate would fit. +const scanBudget = 30 * time.Second -// scanReadOut — how many hosts she names out loud. The rest are a count: a -// spoken list of twenty IP addresses is not an answer. -const scanReadOut = 6 +// scanCacheTTL — how long a scan answer is reused. Two questions in a row used +// to be two full sweeps of the LAN, up to a thousand connections each. The +// network does not change on the scale of a follow-up question, and the cheapest +// packet is the one not sent. +const scanCacheTTL = 2 * time.Minute + +// scanReadOut — how many hosts go into the written record's first lines before +// it says "и ещё N". Nothing reads addresses out loud; see scanSummary. +const scanReadOut = 20 // netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven // never puts a discovery packet on the network. @@ -30,10 +43,21 @@ const scanReadOut = 6 type netWiring struct { scanner *netscan.Scanner subnets []string + // api — where the address list is WRITTEN. The spoken answer is a count + // and a shape, so the detail has to land somewhere readable; a note under + // source "scan:lan" puts it on /history and, through the intake decorator, + // on /events. It is also the only record that Maven put packets on the LAN + // at all. nil ⇒ nothing is written, which is what the tests use. + api ipc.CoreAPI + now func() time.Time + + mu sync.Mutex + cached netscan.Result + cachedAt time.Time } // wireNetScan builds the scanner. nil unless the block is enabled and valid. -func wireNetScan(cfg *config.Config) *netWiring { +func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring { nc, ok := cfg.NetScanner() if !ok { return nil @@ -45,29 +69,125 @@ func wireNetScan(cfg *config.Config) *netWiring { log.Printf("netscan: not wired: %v", err) return nil } - return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets} + return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now} } -// scanSummary answers "какие устройства в сети?" in one line. +// scan runs a scan, or reuses one younger than scanCacheTTL. +func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) { + w.mu.Lock() + defer w.mu.Unlock() + now := w.now() + if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL { + return w.cached, nil + } + scanCtx, cancel := context.WithTimeout(ctx, scanBudget) + defer cancel() + res, err := w.scanner.Scan(scanCtx) + if err != nil { + return res, err + } + w.cached, w.cachedAt = res, now + // Written on a fresh scan only: the record is a trace of packets going out, + // so a cached answer must not forge a second one. + w.writeScanRecord(ctx, res) + return res, nil +} + +// scanSummary answers "какие устройства в сети?" in one spoken line. +// +// It does NOT read addresses out. This is the query path, so the reply goes to +// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is +// a digit stream nobody can follow through a speaker. She says how many and +// what shape they are; the addresses go into a note (see writeScanRecord). func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { if w == nil { return "", false } - ctx, cancel := context.WithTimeout(ctx, scanBudget) - defer cancel() - hosts, err := w.scanner.Scan(ctx) + res, err := w.scan(ctx) if err != nil { log.Printf("netscan: scan: %v", err) return "не получилось просканировать сеть.", true } - if len(hosts) == 0 { - return "в сети никого не нашла.", true + // A truncated run is not a statement about the LAN. Saying "нашла 6 + // устройств" after stopping two thirds of the way through the range is a + // false claim, and the addresses at the end are the ones that go missing. + tail := "" + if res.Truncated { + tail = ", но успела посмотреть не всю сеть" } - shown := hosts + if len(res.Hosts) == 0 { + return "в сети никого не нашла" + tail + ".", true + } + out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + if shape := scanShape(res.Hosts); shape != "" { + out += ", " + shape + } + out += tail + if w.api != nil { + out += ". список записала" + } + return out + ".", true +} + +// scanShape describes the hosts by what they answer on, which is the part of +// the answer that carries meaning out loud: "два с вебом" says more about the +// flat than four octets do. +func scanShape(hosts []netscan.Host) string { + var web, ssh, quiet int + for _, h := range hosts { + hasWeb, hasSSH := false, false + for _, p := range h.Ports { + switch p { + case 80, 443, 8080: + hasWeb = true + case 22: + hasSSH = true + } + } + if hasWeb { + web++ + } + if hasSSH { + ssh++ + } + // No open port at all: seen only through the ARP cache. + if len(h.Ports) == 0 { + quiet++ + } + } + var parts []string + if web > 0 { + parts = append(parts, fmt.Sprintf("%d с вебом", web)) + } + if ssh > 0 { + parts = append(parts, fmt.Sprintf("%d с ssh", ssh)) + } + if quiet > 0 { + parts = append(parts, fmt.Sprintf("%d молча", quiet)) + } + if len(parts) == 0 { + return "" + } + return "из них " + strings.Join(parts, ", ") +} + +// writeScanRecord stores the address list as a note. This is both where the +// detail becomes readable and the only trace that a scan happened at all: a +// scan is a read, but "when did she last put packets on the LAN" deserves an +// answer. +func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) { + if w.api == nil { + return + } + head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + if res.Truncated { + head += " (не вся сеть)" + } + lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")} + shown := res.Hosts if len(shown) > scanReadOut { shown = shown[:scanReadOut] } - parts := make([]string, 0, len(shown)) for _, h := range shown { s := h.Addr if len(h.Ports) > 0 { @@ -77,13 +197,17 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { } s += " (" + strings.Join(ps, ", ") + ")" } - parts = append(parts, s) + if h.MAC != "" { + s += " " + h.MAC + } + lines = append(lines, s) } - out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; ")) - if len(hosts) > len(shown) { - out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown)) + if len(res.Hosts) > len(shown) { + lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown))) + } + if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil { + log.Printf("netscan: write scan note: %v", err) } - return out + ".", true } // hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства, @@ -111,13 +235,25 @@ func isNetworkQuery(u string) bool { if s == "" { return false } + // Whole tokens for the network nouns: the bare substring "сети" is inside + // "посетил", so "сколько машин я посетил?" used to read as a request to + // scan the LAN. The prefix forms below are stems that have no such + // collisions. network := false - for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} { - if strings.Contains(s, w) { + for _, w := range []string{"сеть", "сети", "сетке", "сетку"} { + if homeWord(s, w) { network = true break } } + if !network { + for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} { + if strings.Contains(s, w) { + network = true + break + } + } + } if !network { return false } diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index 7feaaee..df87124 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -2,10 +2,14 @@ package main import ( "context" + "net" + "strconv" "strings" "testing" + "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/ipc" ) func TestWireNetScanOffUnlessEnabled(t *testing.T) { @@ -23,7 +27,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) { }}, } { t.Run(name, func(t *testing.T) { - if w := wireNetScan(cfg); w != nil { + if w := wireNetScan(cfg, nil); w != nil { t.Fatal("the scanner must not wire for this config") } }) @@ -36,7 +40,7 @@ func TestWireNetScanOffUnlessEnabled(t *testing.T) { ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ Subnets: []string{"192.168.1.0/24"}, Enabled: true, - }}) + }}, nil) if ok == nil { t.Fatal("a valid enabled block should wire") } @@ -50,7 +54,7 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) { // Port 1 on loopback: nothing listens and the connection is refused // immediately, so the scan is fast and touches only this machine. Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true, - }}) + }}, nil) if w == nil { t.Fatal("wireNetScan returned nil") } @@ -109,3 +113,59 @@ func TestIsNetworkQuery(t *testing.T) { } } } + +// notingAPI counts the notes a scan writes, and remembers the last one. +type notingAPI struct { + ipc.CoreAPI + n int + last string +} + +func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) { + a.n++ + a.last = text + return int64(a.n), nil +} + +// The spoken answer must not be a list of IP addresses. It goes to piper as +// well as to /chat, and six dotted quads read out as a digit stream is not an +// answer anybody can use. The addresses belong in the written record. +func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + + api := ¬ingAPI{} + w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ + Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true, + }}, api) + if w == nil { + t.Fatal("wireNetScan returned nil") + } + out, claimed := w.scanSummary(context.Background()) + if !claimed { + t.Fatal("the summary did not claim the turn") + } + if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) { + t.Errorf("the spoken reply reads addresses out loud: %q", out) + } + if !strings.Contains(out, "нашла 1 устройство") { + t.Errorf("reply = %q, want a count", out) + } + if api.n != 1 { + t.Fatalf("wrote %d notes, want 1", api.n) + } + if !strings.Contains(api.last, "127.0.0.1") { + t.Errorf("the written record has no addresses: %q", api.last) + } + + // A follow-up question inside the TTL reuses the answer: two questions in + // a row must not be two sweeps of the LAN. + if _, _ = w.scanSummary(context.Background()); api.n != 1 { + t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 3ef93f8..1adf273 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -166,7 +166,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem } // The LAN scanner (Vikunja #257): a read, bounded to the configured // subnets and rate-limited. Off unless the `netscan` block is enabled. - w.netscan = wireNetScan(cfg) + w.netscan = wireNetScan(cfg, coreAPI) matcher := tool.NewMatcher(coreAPI) // ----- weather provider (Open-Meteo when configured, Stub otherwise) ----- diff --git a/deploy/mavend.json b/deploy/mavend.json index 9d4900f..0ea21ad 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -60,7 +60,7 @@ "subnets": ["192.168.1.0/24"], "ports": [22, 80, 443, 8080], "timeout": "400ms", - "rate": 50, + "rate": 100, "max_hosts": 256, "enabled": false }, diff --git a/internal/netscan/netscan.go b/internal/netscan/netscan.go index 8f2b7c9..b2e28fa 100644 --- a/internal/netscan/netscan.go +++ b/internal/netscan/netscan.go @@ -46,8 +46,16 @@ const ( // DefaultTimeout — per-connection budget. Short: on a LAN a live host // answers in single-digit milliseconds, and a filtered port never answers. DefaultTimeout = 400 * time.Millisecond - // DefaultRate — connections per second across the whole scan. - DefaultRate = 50 + // DefaultRate — connections per second across the whole scan. A default + // /24 at four ports is 1016 probes, so this is also what decides whether + // the shipped configuration fits inside the caller's budget: at 100/s it + // takes about ten seconds. Lowering it means a truncated scan, which is + // reported rather than hidden, but it is still a worse answer. + DefaultRate = 100 + // MaxRate — the highest configurable rate. The dial loop floors the ticker + // interval at a millisecond, so anything above this was already a lie; say + // so at config load instead of silently clamping. + MaxRate = 1000 // DefaultMaxHosts — cap on addresses probed in one scan. DefaultMaxHosts = 256 // MaxPrefixHosts — the largest CIDR that may be configured, in addresses. @@ -127,6 +135,9 @@ func Validate(c Config) error { if c.Rate < 0 || c.MaxHosts < 0 || c.Timeout < 0 { return errors.New("netscan: rate, max_hosts and timeout must not be negative") } + if c.Rate > MaxRate { + return fmt.Errorf("netscan: rate %d is above the ceiling of %d connections per second", c.Rate, MaxRate) + } return nil } @@ -177,43 +188,89 @@ func New(cfg Config) *Scanner { return &Scanner{cfg: cfg, dial: dialTCP, arp: readARP} } -// targets expands the configured subnets into addresses, skipping the network -// and broadcast address of each, capped at MaxHosts. Deterministic order, so -// two scans of an unchanged network read the same. -func (s *Scanner) targets() []netip.Addr { +// expand lists the scannable addresses of one CIDR, skipping the network and +// broadcast address. +func expand(cidr string) []netip.Addr { + p, err := netip.ParsePrefix(strings.TrimSpace(cidr)) + if err != nil { + return nil + } + p = p.Masked() + first := p.Addr() var out []netip.Addr - for _, cidr := range s.cfg.Subnets { - p, err := netip.ParsePrefix(strings.TrimSpace(cidr)) - if err != nil { + for a := first; p.Contains(a); a = a.Next() { + // Skip the network address; the broadcast address is skipped by + // looking one ahead. + if a == first && p.Bits() < 31 { continue } - p = p.Masked() - first := p.Addr() - for a := first; p.Contains(a); a = a.Next() { - if len(out) >= s.cfg.MaxHosts { - return out - } - // Skip the network address; the broadcast address is skipped by - // looking one ahead. - if a == first && p.Bits() < 31 { - continue - } - if p.Bits() < 31 && !p.Contains(a.Next()) { - continue - } - out = append(out, a) + if p.Bits() < 31 && !p.Contains(a.Next()) { + continue } + out = append(out, a) } return out } +// targets expands the configured subnets into addresses, capped at MaxHosts, +// and reports whether the cap cut anything off. Deterministic order, so two +// scans of an unchanged network read the same. +// +// The subnets are taken round-robin rather than in order. Consuming MaxHosts +// from the first subnet used to leave a second configured LAN 99% unprobed, +// with nothing logged: he named two ranges and got an answer about one. +// Round-robin spends the budget evenly, so every named range is represented +// and the shortfall is reported instead. +func (s *Scanner) targets() ([]netip.Addr, bool) { + lists := make([][]netip.Addr, 0, len(s.cfg.Subnets)) + total := 0 + for _, cidr := range s.cfg.Subnets { + l := expand(cidr) + if len(l) == 0 { + continue + } + lists = append(lists, l) + total += len(l) + } + out := make([]netip.Addr, 0, min(total, s.cfg.MaxHosts)) + for i := 0; len(out) < s.cfg.MaxHosts; i++ { + took := false + for _, l := range lists { + if i >= len(l) { + continue + } + if len(out) >= s.cfg.MaxHosts { + break + } + out = append(out, l[i]) + took = true + } + if !took { + break + } + } + return out, len(out) < total +} + +// Result is one scan's outcome. +type Result struct { + // Hosts — what answered, ascending by address. + Hosts []Host + // Truncated — the scan did not cover every configured address, because + // MaxHosts cut the target list or the caller's context expired mid-run. + // Callers MUST NOT present a truncated result as the state of the network: + // "нашла 6 устройств" is a claim about the LAN, and a scan that stopped at + // .238 has not earned it. + Truncated bool +} + // Scan probes every configured address and returns the hosts that answered. // // It takes no target: the range is the configured one, always. Callers pass a // context and nothing else, which is the point — see the package comment. -func (s *Scanner) Scan(ctx context.Context) ([]Host, error) { +func (s *Scanner) Scan(ctx context.Context) (Result, error) { if len(s.cfg.Subnets) == 0 { - return nil, ErrNoSubnets + return Result{}, ErrNoSubnets } arp, err := s.arp() if err != nil { @@ -233,8 +290,13 @@ func (s *Scanner) Scan(ctx context.Context) ([]Host, error) { addr string ports []int } - targets := s.targets() - results := make(chan result, len(targets)) + targets, truncated := s.targets() + // One slot per PROBE, not per host: a worker sends once per open port, so + // a subnet with more open ports than addresses used to fill a host-sized + // buffer and wedge. Nothing drains this channel until wg.Wait returns, and + // the sends carry no select on ctx.Done, so that was a permanent hang of + // the calling turn plus a leak of every worker. + results := make(chan result, len(targets)*len(s.cfg.Ports)) sem := make(chan struct{}, maxParallel) var wg sync.WaitGroup @@ -247,10 +309,12 @@ scan: // sometimes win over an already-canceled context and let one more // probe out. if ctx.Err() != nil { + truncated = true break scan } select { case <-ctx.Done(): + truncated = true break scan case <-tick.C: } @@ -294,6 +358,9 @@ scan: out := make([]Host, 0, len(byAddr)) for _, h := range byAddr { sort.Ints(h.Ports) + if !h.Up() { + continue + } out = append(out, *h) } sort.Slice(out, func(i, j int) bool { @@ -301,7 +368,7 @@ scan: aj, _ := netip.ParseAddr(out[j].Addr) return ai.Less(aj) }) - return out, nil + return Result{Hosts: out, Truncated: truncated}, nil } func itoa(n int) string { return fmt.Sprintf("%d", n) } diff --git a/internal/netscan/netscan_test.go b/internal/netscan/netscan_test.go index 3de34bb..772e5e5 100644 --- a/internal/netscan/netscan_test.go +++ b/internal/netscan/netscan_test.go @@ -35,6 +35,7 @@ func TestValidateBounds(t *testing.T) { "bad port": {Subnets: []string{"192.168.1.0/24"}, Ports: []int{0}}, "huge port": {Subnets: []string{"192.168.1.0/24"}, Ports: []int{70000}}, "negative rate": {Subnets: []string{"192.168.1.0/24"}, Rate: -1}, + "rate past the ceiling": {Subnets: []string{"192.168.1.0/24"}, Rate: MaxRate + 1}, } for name, c := range bad { if err := Validate(c); err == nil { @@ -63,10 +64,11 @@ func TestScanOnlyTouchesConfiguredSubnet(t *testing.T) { } s.arp = func() (map[string]string, error) { return map[string]string{}, nil } - hosts, err := s.Scan(context.Background()) + res, err := s.Scan(context.Background()) if err != nil { t.Fatalf("Scan: %v", err) } + hosts := res.Hosts if len(hosts) != 1 || hosts[0].Addr != "192.168.9.3" || len(hosts[0].Ports) != 1 { t.Fatalf("hosts = %+v", hosts) } @@ -144,10 +146,11 @@ func TestARPFillsMACWithinTheConfiguredRangeOnly(t *testing.T) { "10.9.9.9": "11:22:33:44:55:66", }, nil } - hosts, err := s.Scan(context.Background()) + res, err := s.Scan(context.Background()) if err != nil { t.Fatal(err) } + hosts := res.Hosts if len(hosts) != 1 { t.Fatalf("hosts = %+v", hosts) } @@ -196,3 +199,96 @@ func TestNewAppliesDefaults(t *testing.T) { t.Error("New aliased DefaultPorts") } } + +// A dense subnet must not wedge the scan. The results channel used to be sized +// by the number of HOSTS while a worker sends once per open PORT, so a range +// where the open ports outnumber the addresses filled the buffer, blocked a +// worker inside wg.Wait, and hung Scan forever. Nothing drains the channel +// before wg.Wait returns and the sends carry no ctx.Done case, so the caller's +// deadline did not rescue it either. +// +// Six addresses, eight ports, everything open: 48 sends against a buffer that +// used to hold 6. Against the old code this test does not fail, it hangs, so +// the scan runs on its own goroutine with a deadline around it. +func TestScanDoesNotWedgeWhenPortsOutnumberHosts(t *testing.T) { + ports := []int{22, 80, 443, 8080, 8443, 9000, 9100, 9200} + s := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: ports, Rate: MaxRate}) + s.dial = func(context.Context, string, time.Duration) bool { return true } + s.arp = func() (map[string]string, error) { return nil, nil } + + done := make(chan Result, 1) + go func() { + res, err := s.Scan(context.Background()) + if err != nil { + t.Error(err) + } + done <- res + }() + select { + case res := <-done: + if len(res.Hosts) != 6 { + t.Fatalf("hosts = %d, want 6: %+v", len(res.Hosts), res.Hosts) + } + for _, h := range res.Hosts { + if len(h.Ports) != len(ports) { + t.Errorf("%s reported %d open ports, want %d", h.Addr, len(h.Ports), len(ports)) + } + } + case <-time.After(10 * time.Second): + t.Fatal("Scan did not return: the results channel is sized by hosts, not by probes") + } +} + +// MaxHosts is spent evenly across the configured subnets. Taking it in order +// meant a second configured LAN got whatever the first left over, which for a +// pair of /24s under the default cap was two addresses out of 254. +func TestTargetsSpreadAcrossSubnets(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.1.0/24", "192.168.2.0/24"}, MaxHosts: 20}) + targets, truncated := s.targets() + if !truncated { + t.Error("508 addresses under a cap of 20 is a truncated target list") + } + if len(targets) != 20 { + t.Fatalf("targets = %d, want 20", len(targets)) + } + var first, second int + for _, a := range targets { + switch { + case netip.MustParsePrefix("192.168.1.0/24").Contains(a): + first++ + case netip.MustParsePrefix("192.168.2.0/24").Contains(a): + second++ + } + } + if first != 10 || second != 10 { + t.Errorf("split %d/%d across the two subnets, want 10/10", first, second) + } +} + +// A run cut short by the caller's deadline reports itself as truncated, so the +// spoken answer can stop claiming to describe the whole network. +func TestScanReportsTruncation(t *testing.T) { + s := New(Config{Subnets: []string{"192.168.9.0/24"}, Ports: []int{80}, Rate: 200}) + s.dial = func(context.Context, string, time.Duration) bool { return false } + s.arp = func() (map[string]string, error) { return nil, nil } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + res, err := s.Scan(ctx) + if err != nil { + t.Fatal(err) + } + if !res.Truncated { + t.Error("a scan stopped by the deadline must report Truncated") + } + + full := New(Config{Subnets: []string{"192.168.9.0/29"}, Ports: []int{80}, Rate: MaxRate}) + full.dial = func(context.Context, string, time.Duration) bool { return false } + full.arp = func() (map[string]string, error) { return nil, nil } + res, err = full.Scan(context.Background()) + if err != nil { + t.Fatal(err) + } + if res.Truncated { + t.Error("a scan that covered every configured address is not truncated") + } +} diff --git a/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file From d62ba093f5c6a7d53e627545b4dad906faa144ee Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:06:38 +0400 Subject: [PATCH 2/4] smarthome: keep the devices under the cap, and stop asserting what the house did not do States sorted every entity by id and cut at MaxEntities. Entity ids sort by domain prefix, so binary_sensor came first and forty slots went to connectivity and update-available rows: propose found nothing controllable, and homeSummary, reading the same list, said everything was off with the lights on. The cap stays, because a tool name the 1.7B half-remembers is a wrong act. What changes is which forty. Controllable domains are taken first and round-robin, so every switch and light is in before any sensor. CallService reported done for a call that changed nothing. Home Assistant answers a service call with the states it changed, and a removed entity or an offline integration gets 200 and an empty array. That is the one place Maven asserts something about the physical world, so an empty array is now ErrUnknownEntity. The confirm turn on a house row was a column, not an invariant. The proposal is destructive, but /tools writes the checkbox through on enable, so unticking it once made an unlock row that ran on first hearing. Exec now demands the second turn for any smarthome row whatever the column says, and lock is out of the default domain set so a bare block does not propose an unlock for every door. wireSmartHome enumerated the house synchronously, inside wireVoice, before the socket was serving and inside the unlock handler. A box that black-holes the connection held the daemon's start for the per-call timeout. The first propose moved onto the ticker goroutine. Smaller: an unreachable lamp is counted apart from an off one, a truncated on-list says how many it left out, refresh has a floor of a minute, and the http url is documented as a deliberate wg-only choice. Found in review of #80. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/smarthome.go | 51 ++++++++++++--- cmd/mavend/smarthome_test.go | 86 +++++++++++++++++++++++++ internal/config/config.go | 23 ++++++- internal/smarthome/ha.go | 95 +++++++++++++++++++++++++--- internal/smarthome/smarthome_test.go | 82 +++++++++++++++++++++++- internal/tool/tool.go | 9 +++ internal/tool/tool_test.go | 30 +++++++++ 7 files changed, 354 insertions(+), 22 deletions(-) diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go index f4f848c..03946b2 100644 --- a/cmd/mavend/smarthome.go +++ b/cmd/mavend/smarthome.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log" "strings" "time" @@ -44,9 +45,12 @@ func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring { st: st, refresh: time.Duration(cfg.SmartHome.Refresh), } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - w.propose(ctx) + // No first propose here. This runs inside wireVoice, inside run, before the + // IPC socket is serving, and on the locked path inside the passkey unlock + // handler. A Home Assistant box that is powered off but still on a routed + // subnet black-holes the connection rather than refusing it, so a + // synchronous enumeration held the daemon's start for the per-call timeout. + // run does the first propose off the ticker instead. return w } @@ -113,6 +117,9 @@ func (w *homeWiring) run(ctx context.Context) { } t := time.NewTicker(iv) defer t.Stop() + // The first enumeration, off the daemon's start path. wireSmartHome used to + // do it synchronously and a dead house delayed the socket coming up. + w.propose(ctx) for { select { case <-ctx.Done(): @@ -140,28 +147,54 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { } var on []string var sensors []string + dark := 0 for _, e := range ents { switch { case e.Domain == "sensor" || e.Domain == "binary_sensor": - if len(sensors) < 3 && e.State != "" && e.State != "unavailable" { + if e.State == "" || e.State == "unavailable" { + dark++ + continue + } + if len(sensors) < 3 { sensors = append(sensors, e.Name+" "+e.State+e.Unit) } + case e.State == "unavailable" || e.State == "unknown" || e.State == "": + // A lamp that is not reachable is not a lamp that is off. Counting + // it as neither used to make "всё выключено" and "one device is + // unreachable" read identically. + dark++ case e.State == "on" || e.State == "open" || e.State == "unlocked": on = append(on, e.Name) } } var parts []string - if len(on) > 0 { - if len(on) > 5 { - on = on[:5] + switch { + case len(on) > 0: + shown, rest := on, 0 + if len(shown) > 5 { + rest = len(shown) - 5 + shown = shown[:5] } - parts = append(parts, "включено: "+strings.Join(on, ", ")) - } else { + // Silent truncation on a status read is the same failure as the cap + // one layer up: she has to say the list is not the whole list. + line := "включено: " + strings.Join(shown, ", ") + if rest > 0 { + line += fmt.Sprintf(" и ещё %d", rest) + } + parts = append(parts, line) + case dark > 0 && len(sensors) == 0: + // Nothing is on and everything she can see is unreachable. "всё + // выключено" would be a claim about the house she cannot make. + return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true + default: parts = append(parts, "всё выключено") } if len(sensors) > 0 { parts = append(parts, strings.Join(sensors, ", ")) } + if dark > 0 { + parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark))) + } return strings.Join(parts, "; ") + ".", true } diff --git a/cmd/mavend/smarthome_test.go b/cmd/mavend/smarthome_test.go index 41f07c1..d7904ae 100644 --- a/cmd/mavend/smarthome_test.go +++ b/cmd/mavend/smarthome_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" @@ -77,6 +78,12 @@ func TestProposeOnlyProposesControllableDevices(t *testing.T) { if w == nil { t.Fatal("wireSmartHome returned nil for an enabled, reachable house") } + // Wiring alone must not have touched the house: enumeration happens off + // the ticker, not on the daemon's start path. + if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 { + t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err) + } + w.propose(context.Background()) tools, err := st.ListTools(context.Background(), "") if err != nil { @@ -188,3 +195,82 @@ func TestIsHomeQuery(t *testing.T) { } } } + +// A house that black-holes the connection must not hold the daemon's start. +// wireSmartHome used to enumerate synchronously with a 30s context, inside +// wireVoice, inside run, before the IPC socket was serving — and on the locked +// path, inside the passkey unlock handler. +func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) { + // A handler that never answers: the client's own timeout is the only way + // out, and it is ten seconds. + block := make(chan struct{}) + defer close(block) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-block + })) + defer srv.Close() + + done := make(chan *homeWiring, 1) + go func() { + done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{ + URL: srv.URL, Token: "t", Enabled: true, + }}, newTestStore(t)) + }() + select { + case w := <-done: + if w == nil { + t.Fatal("a configured house should still wire") + } + case <-time.After(2 * time.Second): + t.Fatal("wireSmartHome waited on the house") + } +} + +// A lamp that is unreachable is not a lamp that is off, and a list she cut +// short has to say so. Both used to read as plain statements about the house. +func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) { + const fixture = `[ + {"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}}, + {"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}} +]` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(fixture)) + })) + defer srv.Close() + w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{ + URL: srv.URL, Token: "t", Enabled: true, + }}, newTestStore(t)) + out, ok := w.homeSummary(context.Background()) + if !ok { + t.Fatal("summary did not claim the turn") + } + if strings.Contains(out, "всё выключено") { + t.Errorf("two unreachable lamps were reported as off: %q", out) + } + if !strings.Contains(out, "не отвечают") { + t.Errorf("the unreachable devices are not mentioned: %q", out) + } +} + +func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) { + var b strings.Builder + b.WriteString("[") + for i := 0; i < 8; i++ { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i) + } + b.WriteString("]") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(b.String())) + })) + defer srv.Close() + w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{ + URL: srv.URL, Token: "t", Enabled: true, + }}, newTestStore(t)) + out, _ := w.homeSummary(context.Background()) + if !strings.Contains(out, "и ещё 3") { + t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0124e1a..4c7aa41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -287,6 +287,12 @@ type SmartHomeConfig struct { Provider string `json:"provider,omitempty"` // URL — the instance base, "http://192.168.1.50:8123". + // + // Plain http is accepted and is what the deploy block uses. That is a + // deliberate choice, not an oversight: the instance is on the LAN behind + // wireguard, and a self-signed cert on a home box buys a warning rather + // than a guarantee. It does mean the long-lived token crosses the LAN in + // cleartext on every refresh, so the LAN is part of the trust boundary. URL string `json:"url,omitempty"` // Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in @@ -294,8 +300,9 @@ type SmartHomeConfig struct { Token string `json:"token,omitempty"` // Domains — entity domains to take. Empty ⇒ the controllable domains - // (light, switch, fan, cover, lock) plus sensor and binary_sensor for - // reads. Narrow it when the instance is large: a tool name the 1.7B + // EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor + // for reads. A lock is only enumerated when it is named here, because a + // front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B // half-remembers is a wrong act. Domains []string `json:"domains,omitempty"` @@ -306,7 +313,9 @@ type SmartHomeConfig struct { Timeout Duration `json:"timeout,omitempty"` // Refresh — how often the entity list is re-read and new devices proposed. - // 0 ⇒ 15m. Discovery is idempotent, so this only ever adds rows. + // 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it: + // "refresh": "1s" used to pass validation and enumerate the whole instance + // every second. Discovery is idempotent, so this only ever adds rows. Refresh Duration `json:"refresh,omitempty"` // Enabled — false (the default) keeps a written block dark, so it can be @@ -1013,6 +1022,11 @@ const DefaultEmailTimeout = 2 * time.Minute // grow a new lamp every minute. const DefaultSmartHomeRefresh = 15 * time.Minute +// MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating +// every entity in the house is a full /api/states read; a misconfigured second +// would hammer the instance for proposals that are idempotent anyway. +const MinSmartHomeRefresh = time.Minute + // PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server // as a managed subprocess and sends chat-completion requests to phrase nudge // and reminder messages. nil ⇒ the template-based Stub is used instead. @@ -1250,6 +1264,9 @@ func (c *Config) applyDefaults() { if c.SmartHome != nil && c.SmartHome.Refresh <= 0 { c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) } + if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) { + c.SmartHome.Refresh = Duration(MinSmartHomeRefresh) + } // Same rule for the scanner. if c.NetScan != nil && !c.NetScan.Enabled { diff --git a/internal/smarthome/ha.go b/internal/smarthome/ha.go index 096b5a2..ec6d54c 100644 --- a/internal/smarthome/ha.go +++ b/internal/smarthome/ha.go @@ -37,7 +37,8 @@ type Config struct { // logged. Token string // Domains — the entity domains to take. Empty ⇒ every domain in the - // controllable table plus sensor/binary_sensor for reads. + // controllable table EXCEPT lock, plus sensor/binary_sensor for reads. + // A lock is only enumerated when it is named here. Domains []string // MaxEntities — 0 ⇒ DefaultMaxEntities. MaxEntities int @@ -102,7 +103,11 @@ func (c *Client) wanted(domain string) bool { return false } if _, ok := controllable[domain]; ok { - return true + // A deadbolt is a different class of object from a lamp, so "lock" is + // not in the implicit set: a bare url+token block must not auto-propose + // an unlock row for every door in the flat. Naming it in domains is the + // operator saying he meant it. + return domain != "lock" } return domain == "sensor" || domain == "binary_sensor" } @@ -118,9 +123,70 @@ type haAttrs struct { Unit string `json:"unit_of_measurement"` } +// capEntities cuts a state list to at most max entries, taking a controllable +// entity before any sensor and then round-robin across domains. +// +// The cap used to be applied to a globally id-sorted list, and entity ids sort +// by domain prefix: binary_sensor < cover < fan < light < lock < sensor < +// switch. A stock Home Assistant carries dozens of binary_sensor rows before it +// carries anything else, so forty slots went entirely to connectivity and +// update-available sensors. propose then found zero controllable entities, and +// homeSummary, reading the same list, said "всё выключено" with the lights on. +// +// The cap itself stays. The resident model is a 1.7B with a 4096-token context +// and a tool name it half-remembers is a wrong act, so a bounded deliberate +// catalogue still beats a complete one. What changes is which forty: every +// switch and light before any sensor, and an even spread inside each group so +// one crowded domain cannot starve the others. The result is sorted by id, so +// /tools reads the same across restarts. +func capEntities(all []Entity, max int) []Entity { + if max <= 0 || len(all) <= max { + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + return all + } + byDomain := map[string][]Entity{} + for _, e := range all { + byDomain[e.Domain] = append(byDomain[e.Domain], e) + } + var control, read []string + for d := range byDomain { + sort.Slice(byDomain[d], func(i, j int) bool { return byDomain[d][i].ID < byDomain[d][j].ID }) + if _, ok := controllable[d]; ok { + control = append(control, d) + } else { + read = append(read, d) + } + } + sort.Strings(control) + sort.Strings(read) + + out := make([]Entity, 0, max) + take := func(domains []string) { + for i := 0; len(out) < max; i++ { + took := false + for _, d := range domains { + l := byDomain[d] + if i >= len(l) || len(out) >= max { + continue + } + out = append(out, l[i]) + took = true + } + if !took { + return + } + } + } + take(control) + take(read) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + // States reads every entity Maven cares about, sorted by id and capped at // MaxEntities so the catalogue is deterministic across restarts — a proposal -// list that reshuffles itself would make /tools unreadable. +// list that reshuffles itself would make /tools unreadable. See capEntities for +// what the cap keeps. func (c *Client) States(ctx context.Context) ([]Entity, error) { body, err := c.do(ctx, http.MethodGet, "/api/states", nil) if err != nil { @@ -150,11 +216,7 @@ func (c *Client) States(ctx context.Context) ([]Entity, error) { } out = append(out, e) } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - if len(out) > c.cfg.MaxEntities { - out = out[:c.cfg.MaxEntities] - } - return out, nil + return capEntities(out, c.cfg.MaxEntities), nil } // CallService performs one service call against one entity and returns a short @@ -188,9 +250,24 @@ func (c *Client) CallService(ctx context.Context, entityID, service string) (str return "", fmt.Errorf("smarthome: encode call: %w", err) } path := "/api/services/" + url.PathEscape(domain) + "/" + url.PathEscape(service) - if _, err := c.do(ctx, http.MethodPost, path, payload); err != nil { + body, err := c.do(ctx, http.MethodPost, path, payload) + if err != nil { return "", err } + // Home Assistant answers a service call with the states it changed. An + // entity that was removed since discovery, or one whose integration is + // offline, gets 200 and an empty array. Reporting "готово" for that is + // Maven asserting something false about the physical world: he says + // "выключи свет", she says done, the light stays on. + var changed []haState + if err := json.Unmarshal(body, &changed); err != nil { + // A shape we cannot read is not evidence of failure. HA has answered + // 2xx, so report the call as made rather than inventing a fault. + return "готово", nil + } + if len(changed) == 0 { + return "", fmt.Errorf("%w: %s did not change anything", ErrUnknownEntity, entityID) + } return "готово", nil } diff --git a/internal/smarthome/smarthome_test.go b/internal/smarthome/smarthome_test.go index 48347ea..34bac98 100644 --- a/internal/smarthome/smarthome_test.go +++ b/internal/smarthome/smarthome_test.go @@ -3,6 +3,7 @@ package smarthome import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -97,7 +98,7 @@ func TestCallServicePostsEntityID(t *testing.T) { b := make([]byte, 256) n, _ := r.Body.Read(b) body = string(b[:n]) - _, _ = w.Write([]byte(`[]`)) + _, _ = w.Write([]byte(`[{"entity_id":"light.living_room","state":"off"}]`)) }) out, err := c.CallService(context.Background(), "light.living_room", "turn_off") if err != nil { @@ -209,3 +210,82 @@ func TestAllowlistEncoding(t *testing.T) { t.Error("DomainOf") } } + +// Home Assistant answers a service call with the states it changed, and an +// entity that has been removed or whose integration is offline gets 200 and an +// empty array. "готово" for that is Maven asserting something false about the +// physical world: he says выключи свет, she says done, the light stays on. +func TestCallServiceOnAnEntityThatChangedNothing(t *testing.T) { + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }) + if _, err := c.CallService(context.Background(), "light.living_room", "turn_off"); !errors.Is(err, ErrUnknownEntity) { + t.Errorf("err = %v, want ErrUnknownEntity for a call that changed nothing", err) + } + // A response shape we cannot parse is not evidence of failure: HA answered + // 2xx, so the call is reported as made. + c2, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"result":"ok"}`)) + }) + if out, err := c2.CallService(context.Background(), "light.living_room", "turn_off"); err != nil || out != "готово" { + t.Errorf("out,err = %q,%v", out, err) + } +} + +// The cap must not be spent on sensors. Entity ids sort by domain prefix and +// binary_sensor sorts first, so a globally sorted truncation handed all forty +// slots to connectivity sensors: propose found nothing controllable and +// homeSummary said "всё выключено" with the lights on. +func TestCapKeepsControllableEntitiesFirst(t *testing.T) { + var all []Entity + for i := 0; i < 40; i++ { + all = append(all, Entity{ID: fmt.Sprintf("binary_sensor.b%02d", i), Domain: "binary_sensor", State: "off"}) + } + for i := 0; i < 30; i++ { + all = append(all, Entity{ID: fmt.Sprintf("sensor.s%02d", i), Domain: "sensor", State: "1"}) + } + for i := 0; i < 4; i++ { + all = append(all, Entity{ID: fmt.Sprintf("switch.w%d", i), Domain: "switch", State: "on"}) + } + for i := 0; i < 3; i++ { + all = append(all, Entity{ID: fmt.Sprintf("light.l%d", i), Domain: "light", State: "on"}) + } + got := capEntities(all, 40) + if len(got) != 40 { + t.Fatalf("kept %d entities, want 40", len(got)) + } + kept := map[string]int{} + for _, e := range got { + kept[e.Domain]++ + } + if kept["switch"] != 4 || kept["light"] != 3 { + t.Errorf("kept %d switches and %d lights, want all 4 and all 3: %v", kept["switch"], kept["light"], kept) + } + // The remainder still carries readable sensors, spread across both sensor + // domains rather than exhausting the one that sorts first. + if kept["sensor"] == 0 || kept["binary_sensor"] == 0 { + t.Errorf("the read domains were starved: %v", kept) + } + // Deterministic across restarts: /tools has to read the same each time. + for i := 1; i < len(got); i++ { + if got[i-1].ID >= got[i].ID { + t.Fatalf("output is not sorted by id at %d", i) + } + } +} + +// A deadbolt is a different class of object from a lamp. A bare url+token block +// must not auto-propose an unlock row for every door in the flat. +func TestLockIsNotInTheDefaultDomains(t *testing.T) { + c := NewClient(Config{URL: "http://x", Token: "t"}) + if c.wanted("lock") { + t.Error("lock is enumerated without being named in domains") + } + if !c.wanted("light") || !c.wanted("sensor") { + t.Error("the ordinary default domains were lost") + } + named := NewClient(Config{URL: "http://x", Token: "t", Domains: []string{"lock"}}) + if !named.wanted("lock") { + t.Error("lock named in domains is still not enumerated") + } +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 4c0165d..c2ffdae 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -150,6 +150,15 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm if e.home == nil { return "", ErrNotEnabled } + // The confirm turn on a house row is structural, not a column. The + // proposal is written destructive=true, but /tools writes the checkbox + // straight through on enable (destructive=excluded.destructive), so + // unticking it once turned home_lock_front_door_unlock into a row that + // ran on first hearing. Nothing any surface writes can remove the + // second turn from a physical device. + if !confirmed { + return "", ErrNeedsConfirm + } ctx, cancel := context.WithTimeout(ctx, e.timeout) defer cancel() return e.home.CallService(ctx, entityID, service) diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index cfc4c35..b6f8049 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -262,3 +262,33 @@ func TestExecSmartHomeRow(t *testing.T) { t.Fatal(`"smarthome" was run as a binary`) } } + +// The confirm turn on a house row survives the destructive column being wrong. +// ProposeSmartHomeTool writes destructive=true, but /tools reads the checkbox +// from the form and EnableTool writes destructive=excluded.destructive, so +// unticking it once turned home_lock_front_door_unlock into a row that opened +// the front door on first hearing. The guarantee has to be structural. +func TestExecSmartHomeRowConfirmsEvenWhenNotMarkedDestructive(t *testing.T) { + api := fakeAPI{tools: map[string]ipc.Tool{ + "home_lock_front_door_unlock": { + Name: "home_lock_front_door_unlock", Scope: "smarthome:lock", + Cmd: []string{"smarthome", "lock.front_door", "unlock"}, + // The column Kami unticked on /tools. + Destructive: false, Status: "enabled", + }, + }} + fh := &fakeHome{} + e := NewExecutor(api, time.Second).WithHome(fh) + if _, err := e.Exec(context.Background(), "home_lock_front_door_unlock", nil, false); !errors.Is(err, ErrNeedsConfirm) { + t.Fatalf("err = %v, want ErrNeedsConfirm", err) + } + if fh.calls != 0 { + t.Fatal("the front door was unlocked without a confirm turn") + } + if _, err := e.Exec(context.Background(), "home_lock_front_door_unlock", nil, true); err != nil { + t.Fatalf("confirmed: %v", err) + } + if fh.calls != 1 { + t.Fatalf("calls = %d, want 1 after the confirm turn", fh.calls) + } +} From 9e383eb7511c800127e19c62b5be2d492077c307 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:10:36 +0400 Subject: [PATCH 3/4] event: order the journal by notice time, and keep it to what arrived The ring is insertion-ordered and the page called itself newest first while printing OccurredAt, which is when the thing happened. A cold feed read publishes a week of items in feed order and the ambient relay stamps a 09:00 notification with an 18:00 meeting, so the timestamp column ran forwards and backwards on the same page. Events now carry NoticedAt, filled by the bus and not by the caller, and the page sorts and labels by it while still showing when the thing itself happened. Four writers on that page had not arrived from anywhere: the feed watermark, the crawl hash, the praxis trace of an act she performed and a quiet-hours toggle he pressed. On a cold start with a few feeds they could evict real intake out of a 512-entry ring. The decorator now skips Maven's own bookkeeping. Priority was the only surviving trace of confidence, and it inverts: a relayed meeting at 0.6 read as low while an rss watermark at 1.0 read as normal. The fact's own kind, its confidence and the id it voids now travel in Payload, which was unused. A retraction is marked as one and scored low, instead of publishing an envelope indistinguishable from a fresh reading of the same key. Smaller: SourceKind no longer maps every email source to a task, so a future fact under an email prefix is not journalled as one; newEventBus is quiet when it is handed no config at all; and morningTmpl has its own doc comment back. Found in review of #78. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- cmd/mavend/intake.go | 83 ++++++++++++++++++++++++-- cmd/mavend/intake_test.go | 109 +++++++++++++++++++++++++++++++++++ cmd/mavweb/events.html | 14 +++-- cmd/mavweb/main.go | 8 +-- internal/event/bus.go | 10 +++- internal/event/event.go | 21 ++++++- internal/event/event_test.go | 46 ++++++++++++++- internal/ipc/api.go | 4 ++ 8 files changed, 274 insertions(+), 21 deletions(-) diff --git a/cmd/mavend/intake.go b/cmd/mavend/intake.go index 0e7542d..ba4954a 100644 --- a/cmd/mavend/intake.go +++ b/cmd/mavend/intake.go @@ -40,6 +40,7 @@ package main import ( "context" + "encoding/json" "log" "strings" "time" @@ -54,7 +55,12 @@ import ( // off (a negative config.intake_journal). nil is the "behave exactly as before" // value all the way down: no decorator, no ring, no /events rows. func newEventBus(cfg *config.Config) *event.Bus { - if cfg == nil || cfg.IntakeJournal < 0 { + if cfg == nil { + // No config at all is a test, not an operator decision. Saying "off" + // here was noise in every suite that passes nil. + return nil + } + if cfg.IntakeJournal < 0 { log.Printf("intake journal: off (intake_journal < 0)") return nil } @@ -85,6 +91,7 @@ func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent { Body: e.Body, Priority: e.Priority, OccurredAt: e.OccurredAt, + NoticedAt: e.NoticedAt, }) } return out @@ -120,18 +127,34 @@ func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, if err != nil { return id, err } + if selfWrite(req) { + // Maven's own bookkeeping is not something that arrived. The feed + // watermark, the crawl hash, the praxis trace of an act she performed + // and a quiet-hours toggle he pressed all used to sit on a page headed + // "everything that arrived", and on a cold start a handful of feeds + // could evict real intake behind their marks. + return id, nil + } // OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake // instant and the ambient path carries the meeting's start. Flattening // those to notice-time would make the journal lie about when things // happened, which is the one thing it is for. + title := req.Key + if req.VoidsID != nil { + // A retraction is not a reading. Without this it published an envelope + // indistinguishable from a fresh value for the same key, on a page + // whose whole job is "what came in". + title = "отмена: " + req.Key + } a.bus.Publish(event.Event{ Source: req.Source, Kind: event.SourceKind(req.Source, event.KindFact), - Title: req.Key, + Title: title, Body: req.Value, Priority: factPriority(req), OccurredAt: req.Ts, EntityIDs: entityIDs(req.Subject), + Payload: factPayload(req), }, a.now()) return id, nil } @@ -201,17 +224,69 @@ func publishableTask(t store.Task, now time.Time) event.Event { } } +// selfWrite reports whether a fact write is Maven describing her own state +// rather than something arriving from outside. The store's fact kinds are +// 'self', 'env' and 'config'; 'config' is where every watermark and toggle +// lands, and the praxis trace is an audit record of an act she performed, which +// is the same class of thing under an 'env' kind. +func selfWrite(req ipc.WriteFactReq) bool { + switch req.Kind { + case "config", "system": + return true + } + return strings.HasPrefix(req.Source, "praxis:trace") +} + // factPriority is the attention hint for a fact write. Deliberately crude: // a low-confidence inference (the ambient notification path writes below 1.0) -// is worth less attention than a read he or a credentialled poller made, and -// nothing else is distinguishable from here. +// is worth less attention than a read he or a credentialled poller made, and a +// retraction is a correction rather than news. +// +// Confidence is NOT recoverable from this, which is why the number itself goes +// into Payload: three display buckets must not be the only surviving trace of +// the distinction internal/calendar went out of its way to keep. func factPriority(req ipc.WriteFactReq) string { + if req.VoidsID != nil { + return event.PriorityLow + } if req.Confidence > 0 && req.Confidence < 1.0 { return event.PriorityLow } return event.PriorityNormal } +// factDetail is the fact-shaped Payload: the fields the envelope's own flat +// shape cannot carry, kept so a reader can tell an inference from a +// credentialled read, and "nobody said" from "certain". +type factDetail struct { + // FactKind — the fact's own kind ('self', 'env', 'config'), a different + // taxonomy from Event.Kind. + FactKind string `json:"fact_kind,omitempty"` + // Confidence — the number itself, so an inference stays distinguishable + // from a credentialled read. nil when the writer set none, which the ipc + // layer rejects today; the pointer keeps "nobody said" and "certain" from + // collapsing into each other the way the priority bucket does. + Confidence *float64 `json:"confidence,omitempty"` + // VoidsID — the fact this one retracts. + VoidsID *int64 `json:"voids_id,omitempty"` +} + +func factPayload(req ipc.WriteFactReq) json.RawMessage { + d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID} + if req.Confidence != 0 { + c := req.Confidence + d.Confidence = &c + } + if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil { + return nil + } + b, err := json.Marshal(d) + if err != nil { + return nil + } + return b +} + // entityIDs turns a fact's free-text Subject into the EntityIDs slot when it // already looks resolved. Intake runs BEFORE the fact enrichment worker // resolves a subject against Nexus, so this is almost always empty — the slot diff --git a/cmd/mavend/intake_test.go b/cmd/mavend/intake_test.go index 1105628..a73103d 100644 --- a/cmd/mavend/intake_test.go +++ b/cmd/mavend/intake_test.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "errors" + "strings" "testing" "time" @@ -176,3 +178,110 @@ func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) { t.Errorf("got %d events, want none", len(got)) } } + +// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash, +// the praxis trace of an act she performed and the quiet-hours toggle he +// pressed all landed on a page headed "everything that arrived", and on a cold +// start a handful of feeds could evict real intake behind their marks. +func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + for _, req := range []ipc.WriteFactReq{ + {Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0}, + {Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0}, + {Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0}, + {Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0}, + } { + if _, err := api.WriteFact(ctx, req); err != nil { + t.Fatalf("WriteFact(%s): %v", req.Key, err) + } + } + if n := bus.Len(); n != 0 { + t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0)) + } + // A real arrival under the same decorator still lands. + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200", + Source: "poll:zenmoney", Confidence: 1.0, + }); err != nil { + t.Fatal(err) + } + if bus.Len() != 1 { + t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0)) + } +} + +// Confidence is the distinction between an inference and a credentialled read, +// and the three-value priority bucket cannot carry it: unset and 1.0 land in +// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it. +func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка", + Source: "ambient:notif", Confidence: 0.6, + }); err != nil { + t.Fatal(err) + } + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0, + }); err != nil { + t.Fatal(err) + } + got := bus.Recent(0) + if len(got) != 2 { + t.Fatalf("journal has %d entries, want 2", len(got)) + } + var relayed, stated factDetail + if err := json.Unmarshal(got[1].Payload, &relayed); err != nil { + t.Fatalf("payload: %v", err) + } + if relayed.Confidence == nil || *relayed.Confidence != 0.6 { + t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence) + } + if relayed.FactKind != "env" { + t.Errorf("fact_kind = %q, want env", relayed.FactKind) + } + // Both writes land in PriorityNormal or PriorityLow buckets that cannot be + // told apart from the outside; the payload is where the two numbers stay + // distinguishable. + if err := json.Unmarshal(got[0].Payload, &stated); err != nil { + t.Fatalf("payload: %v", err) + } + if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" { + t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated) + } +} + +// A retraction is not an observation. It used to publish an envelope +// indistinguishable from a fresh reading of the same key. +func TestIntakeMarksARetraction(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + id, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0, + }) + if err != nil { + t.Fatal(err) + } + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web", + Confidence: 1.0, VoidsID: &id, + }); err != nil { + t.Fatal(err) + } + e := bus.Recent(1)[0] + if e.Priority != event.PriorityLow { + t.Errorf("priority = %q, want low for a correction", e.Priority) + } + if !strings.HasPrefix(e.Title, "отмена:") { + t.Errorf("title = %q, want it marked as a retraction", e.Title) + } + var d factDetail + if err := json.Unmarshal(e.Payload, &d); err != nil { + t.Fatal(err) + } + if d.VoidsID == nil || *d.VoidsID != id { + t.Errorf("voids_id = %v, want %d", d.VoidsID, id) + } +} diff --git a/cmd/mavweb/events.html b/cmd/mavweb/events.html index df655e9..a344c98 100644 --- a/cmd/mavweb/events.html +++ b/cmd/mavweb/events.html @@ -1,17 +1,21 @@ {{template "shellTop" "events"}}

Intake

-
Everything that arrived, newest first — a relayed notification, a mail candidate, a feed -item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the -fact, note or task itself. Held in memory only, so a restart empties this.
+
Everything that arrived, most recently noticed first — a relayed notification, a mail +candidate, a feed item, a changed page, a spend, a presence probe. Maven's own bookkeeping writes (feed +watermarks, crawl hashes, act traces, settings he toggled) are not here: nothing arrived. noticed +is when the journal saw it, happened is when the thing itself did, and those differ by days on a +cold feed read. One envelope per write; the durable row is still the fact, note or task itself. Held in +memory only, so a restart empties this.
{{if .Err}}
journal unavailable: {{.Err}}
{{end}} {{if and (not .Events) (not .Err)}}
nothing has arrived yet
{{end}} {{if .Events}}
- + {{range .Events}} - + + diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 17e49af..aac1666 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -318,14 +318,14 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel // human surface is here (they ship no web UI of their own). var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML)) -// morningTmpl — read-only view of today's checklist state per configured -// morning routine (internal/morning). Same shape as trace.html: a plain -// server-rendered page, refreshed on reload — no live-update loop, since -// checklist state changes on the scale of minutes, not seconds. // eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same // shape as trace.html and morning.html: server-rendered, refreshed on reload. var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML)) +// morningTmpl — read-only view of today's checklist state per configured +// morning routine (internal/morning). Same shape as trace.html: a plain +// server-rendered page, refreshed on reload — no live-update loop, since +// checklist state changes on the scale of minutes, not seconds. var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML)) func noCache(h http.Handler) http.Handler { diff --git a/internal/event/bus.go b/internal/event/bus.go index 5b29a16..72d0c0c 100644 --- a/internal/event/bus.go +++ b/internal/event/bus.go @@ -95,8 +95,14 @@ func (b *Bus) Subscribe(fn func(Event)) { b.subs = append(b.subs, fn) } -// Recent returns up to limit events, newest first. limit <= 0 returns -// everything held. Safe on a nil receiver (returns nil). +// Recent returns up to limit events, most recently NOTICED first. limit <= 0 +// returns everything held. Safe on a nil receiver (returns nil). +// +// The order is the ring's insertion order, which is NoticedAt order, and it is +// deliberately not OccurredAt order: a cold feed read publishes a week of items +// in feed order, so sorting by when things happened would put a six-day-old +// item above one that arrived before it. Readers must label the column +// accordingly. func (b *Bus) Recent(limit int) []Event { if b == nil { return nil diff --git a/internal/event/event.go b/internal/event/event.go index add6420..64a8457 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -79,6 +79,18 @@ type Event struct { // the envelope must not flatten it. OccurredAt time.Time `json:"occurred_at"` + // NoticedAt — when Maven saw it. This is the ring's own ordering and the + // one the page sorts and labels by. + // + // It exists because OccurredAt must stay truthful and therefore cannot be + // an arrival order. A feed's first read returns twenty items spread over a + // week and publishes them in feed order; the ambient relay writes a 09:00 + // notification about an 18:00 meeting. Ordering the journal by OccurredAt + // while walking the ring backwards made the timestamp column run forwards + // and backwards on the same page. Normalize fills it from now, always: a + // caller does not get to say when Maven noticed something. + NoticedAt time.Time `json:"noticed_at"` + // Payload — source-specific extra, opaque here. Optional. Payload json.RawMessage `json:"payload,omitempty"` } @@ -143,6 +155,8 @@ func (e Event) Normalize(now time.Time) Event { if e.OccurredAt.IsZero() { e.OccurredAt = now } + // Notice time is not a caller's to set: it is the instant the bus took it. + e.NoticedAt = now return e } @@ -184,13 +198,14 @@ func truncateRunes(s string, n int) string { // a switch per writer: the source prefix already tells you what arrived. // // Unknown prefixes get fallback, which is what the caller was going to write -// anyway (a WriteFact call knows it is a fact). +// anyway (a WriteFact call knows it is a fact). There is deliberately no +// "email:" rule: the mail path constructs its task envelope itself, so mapping +// the prefix here would have journalled any future fact or note written under +// an email source as a captured task. func SourceKind(source, fallback string) string { switch { case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"): return KindNote - case strings.HasPrefix(source, "email:"): - return KindTask case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"): return KindHealth } diff --git a/internal/event/event_test.go b/internal/event/event_test.go index 0eaec48..27bffa6 100644 --- a/internal/event/event_test.go +++ b/internal/event/event_test.go @@ -78,9 +78,11 @@ func TestValid(t *testing.T) { func TestSourceKind(t *testing.T) { cases := map[string]string{ - "rss:tech": KindNote, - "crawl:kernel": KindNote, - "email:inbox": KindTask, + "rss:tech": KindNote, + "crawl:kernel": KindNote, + // No email rule: a WriteFact under an email source is a fact, not a + // captured task. The mail path builds its own task envelope. + "email:inbox": KindFact, "probe:netdata": KindHealth, "ambient:notif": KindFact, "tap:voice": KindFact, @@ -183,3 +185,41 @@ func TestBusConcurrentPublish(t *testing.T) { t.Errorf("Len = %d, want 160", b.Len()) } } + +// The ring is insertion-ordered and the page calls itself newest first, so the +// two only agree if the column is notice time. A cold feed read publishes a +// week of items in feed order, which used to make the OccurredAt column walk +// forwards and backwards on the same page. +func TestRecentIsOrderedByNoticeTimeNotByWhenThingsHappened(t *testing.T) { + b := NewBus(8) + // Published in feed order, six days old first, and a mark stamped now. + for i, e := range []Event{ + {Source: "rss:t", Kind: KindNote, Title: "six days ago", OccurredAt: testNow.Add(-6 * 24 * time.Hour)}, + {Source: "rss:t", Kind: KindNote, Title: "two days ago", OccurredAt: testNow.Add(-2 * 24 * time.Hour)}, + {Source: "ambient:notif", Kind: KindFact, Title: "a meeting at six", OccurredAt: testNow.Add(9 * time.Hour)}, + } { + b.Publish(e, testNow.Add(time.Duration(i)*time.Second)) + } + got := b.Recent(0) + want := []string{"a meeting at six", "two days ago", "six days ago"} + for i, w := range want { + if got[i].Title != w { + t.Errorf("Recent()[%d] = %q, want %q (notice order)", i, got[i].Title, w) + } + } + // Notice time is monotone down the page even where occurrence time is not. + for i := 1; i < len(got); i++ { + if got[i].NoticedAt.After(got[i-1].NoticedAt) { + t.Errorf("NoticedAt is not descending at %d", i) + } + } + if got[0].OccurredAt.Before(got[1].OccurredAt) { + t.Fatal("this fixture is supposed to have occurrence time out of order") + } + // A caller does not get to claim when Maven noticed something. + forged := Event{Source: "s", Kind: KindFact, Title: "t", NoticedAt: testNow.Add(100 * time.Hour)} + b.Publish(forged, testNow) + if n := b.Recent(1)[0].NoticedAt; !n.Equal(testNow) { + t.Errorf("NoticedAt = %v, want the publish instant %v", n, testNow) + } +} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 30cc4ab..b8863fe 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -685,6 +685,10 @@ type IntakeEvent struct { Body string `json:"body,omitempty"` Priority string `json:"priority"` OccurredAt time.Time `json:"occurred_at"` + // NoticedAt — when the journal took it. This is the order the ring returns + // and the one a page must sort and label by; OccurredAt is when the thing + // happened, which for a cold feed read is a week before it arrived. + NoticedAt time.Time `json:"noticed_at"` } // --- Rule trace / explanation DTOs --- From d0e98a941960843772e945a837494fcfeae39cba Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 14:22:14 +0400 Subject: [PATCH 4/4] simulator: make the negative assertions mean something, and give the ecosystem a scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expect_not_called could pass on a step that made the forbidden call. callPaths concatenates per server and callCount was a total, so slicing the concatenated list by the total examined the wrong window. With praxis on three requests and nexus on one, a fourth praxis call landed at index three and paths[4:] never saw it, while the stale nexus call was reported as new. The mark is now per server and the paths are taken per server from it. Neither scenario ever produced an act, so all three fakes saw zero requests and the fault lever changed no outcome. The two headline capabilities of the harness had no coverage. act_degraded scripts an act against an enabled allowlist row and runs it healthy, at 503 and healthy again, asserting the reply, the call, the absence of a call on a tick, and that nothing was pushed at him either way. That needed two seams the world did not have: allowlist rows from the scenario, and a matcher on the real store rather than a nil API, which would have panicked the moment any scenario produced an act. expect_no_events compared bus.Len(), which stops growing at the ring capacity, so a scenario long enough to fill the ring made every later expect_no_events pass unconditionally. It counts publishes through a subscriber now. A scenario could not express a fact below full confidence, because write hardcoded 1.0, and morning_missed annotated its ambient step as if it could. factPriority branches on exactly that, so no replay could reach the low branch. signalStep takes a confidence, the ambient step sets the 0.6 the ambient path writes, and the event line carries the priority so a scenario can assert it. TestSimulatorIsDeterministic compared the transcript against time.Now, which fails for the half hour a day the scenario itself covers. It checks that every stamped line falls inside the scenario span instead. TestSimulatorRefusesBackwardsSteps tested the forwards case, because reaching the backwards branch ended the test. A fatalf seam makes the refusal observable. Smaller notes: the step doc comment now states which assertions are run scoped and which are step scoped, audioText parses the golden manifest once per world rather than once per step, and the feminine checks list the masculine form with its following character, since the earlier check on a comma alone passed on "записал что ты выпил воды". Found in review of #79. --- cmd/mavend/simulator_test.go | 338 +++++++++++++++--- .../testdata/scenarios/act_degraded.json | 58 +++ .../testdata/scenarios/evening_degraded.json | 2 +- .../testdata/scenarios/morning_missed.json | 13 +- 4 files changed, 357 insertions(+), 54 deletions(-) create mode 100644 cmd/mavend/testdata/scenarios/act_degraded.json diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index df00c2f..7e58ae5 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -55,8 +55,10 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -102,9 +104,22 @@ type scenario struct { Nexus string `json:"nexus_resolve,omitempty"` Hexis string `json:"hexis_capabilities,omitempty"` + // Tools — allowlist rows to enable before the first step. An act is only + // dispatched when its verb is on the enabled allowlist, so a scenario that + // wants to exercise one has to say which rows Kami had enabled. + Tools []toolRow `json:"tools,omitempty"` + Steps []step `json:"steps"` } +// toolRow — one enabled allowlist row. Cmd is empty for an ecosystem verb, +// which is intercepted before the process executor is ever reached. +type toolRow struct { + Name string `json:"name"` + Cmd []string `json:"cmd,omitempty"` + Destructive bool `json:"destructive,omitempty"` +} + // scriptEntry — one canned model answer. Match is a substring of the user // message; the first entry whose Match is contained in it wins, and an entry // with an empty Match is the catch-all. @@ -125,6 +140,16 @@ type scriptEntry struct { // and then asserts. Assertions are evaluated against everything recorded since // the run began, except expect_no_send and expect_no_call, which are scoped to // this step — "nothing was sent because of THIS" is the useful question. +// +// The asymmetry is worth stating plainly, because it changes what a scenario +// author is writing. expect_sent_contains, expect_called and expect_events are +// RUN-scoped: they pass if the thing ever happened, at any earlier step. So +// repeating expect_events: ["rss:tech"] on a later step asserts nothing new, +// it just re-checks the earlier arrival. The negatives — expect_no_send, +// expect_not_called, expect_no_events — are STEP-scoped, and are the ones that +// say something about this moment. expect_reply_contains and +// expect_reply_lacks read the most recent reply only, so a step with no +// utterance re-checks the previous one. type step struct { At string `json:"at"` Note string `json:"note,omitempty"` @@ -176,6 +201,13 @@ type signalStep struct { Value string `json:"value"` Source string `json:"source"` Kind string `json:"kind,omitempty"` + + // Confidence — 0 ⇒ 1.0, an observation Maven made herself. A relayed + // notification is not that: the ambient path writes + // calendar.AmbientConfidence, 0.6, and factPriority in intake.go branches + // on exactly that difference. The field exists so a replay can reach the + // low branch, which it could not while write() hardcoded 1.0. + Confidence float64 `json:"confidence,omitempty"` } type arriveStep struct { @@ -225,6 +257,28 @@ type simWorld struct { // broken scenario is diagnosable without a debugger. transcript []string replies []string + + // fatalf — the abort seam. Defaults to t.Fatalf. It exists so a test can + // reach the harness's own refusals (a backwards step, a missing WAV) and + // assert on them instead of dying with the scenario. + fatalf func(format string, args ...any) + + // published — how many events the bus accepted, counted through a + // subscriber. bus.Len() saturates at the ring capacity and cannot answer + // "did anything arrive during this step" once a long scenario has filled + // it. + mu sync.Mutex + published int + + // audio — golden_v1.json, parsed once. A scenario with twenty audio steps + // used to read and parse the manifest twenty times. + audio map[string]string +} + +func (w *simWorld) publishCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.published } // recordingSink captures every send, mutex-guarded (the tick loop dispatches @@ -324,6 +378,25 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { t: t, clock: clock, loc: start.Location(), start: start, store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted, } + w.fatalf = t.Fatalf + bus.Subscribe(func(event.Event) { + w.mu.Lock() + w.published++ + w.mu.Unlock() + }) + + // Allowlist rows the scenario asked for, enabled before the first step. An + // act only reaches a dispatch if a verb is on the enabled list, so without + // this a scenario cannot script one at all. + for _, tr := range sc.Tools { + cmd := tr.Cmd + if len(cmd) == 0 { + cmd = []string{"true"} + } + if err := st.EnableTool(context.Background(), tr.Name, cmd, tr.Destructive, "sim", start); err != nil { + t.Fatalf("scenario %q: enabling tool %q: %v", sc.Name, tr.Name, err) + } + } // Ecosystem fakes, wired only when the scenario supplies a body — a box // with no praxis block has no praxis client, and a scenario must be able to @@ -345,7 +418,10 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { // classifier in is deliberate; it is the failure floor, and a scenario that // scripts no route for an utterance exercises it. emb := router.NewHashEmbedder(1024) - matcher := tool.NewMatcher(nil) + // The matcher reads the live enabled allowlist, same as the daemon's. It + // used to be built on a nil API, which meant any scenario that produced an + // act panicked the moment the matcher was consulted. + matcher := tool.NewMatcher(api) rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted)) w.handler = &reactiveHandler{ @@ -355,6 +431,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { embedder: emb, api: api, matcher: matcher, + tools: tool.NewExecutor(api, 5*time.Second), phraser: phraser.NewStub(), replier: newLLMReplier(scripted, nil), now: clock.Now, @@ -416,8 +493,9 @@ func (w *simWorld) advanceTo(at string) { target := w.timeOf(at) now := w.clock.Now() if target.Before(now) { - w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order", + w.fatalf("step at %s goes backwards from %s — scenario steps must be in order", at, now.In(w.loc).Format("15:04:05")) + return } w.clock.Advance(target.Sub(now)) } @@ -446,8 +524,8 @@ func (w *simWorld) run(sc scenario) { w.logf("# %s", s.Note) } sendsBefore := w.sink.count() - callsBefore := w.callCount() - eventsBefore := w.bus.Len() + callsBefore := w.callMark() + eventsBefore := w.publishCount() w.stimulate(ctx, s) w.assert(i, s, sendsBefore, callsBefore, eventsBefore) @@ -503,10 +581,14 @@ func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) { if kind == "" { kind = "env" } + conf := sig.Confidence + if conf == 0 { + conf = 1.0 + } if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{ - Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0, + Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: conf, }); err != nil { - w.t.Fatalf("write fact %s: %v", sig.Key, err) + w.fatalf("write fact %s: %v", sig.Key, err) } } @@ -552,29 +634,39 @@ func (w *simWorld) arrive(ctx context.Context, a arriveStep) { // known to contain, by reading cmd/mavsttd's golden manifest (#288's format, // reused rather than duplicated). An unknown reference fails the scenario // rather than quietly transcribing to "". +// +// The manifest is read and parsed once per world, not once per step: a +// scenario with twenty audio steps should cost one file read. func (w *simWorld) audioText(ref string) string { w.t.Helper() manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json") - raw, err := os.ReadFile(manifest) - if err != nil { - w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err) - } - var m struct { - Cases []struct { - Name string `json:"name"` - WAV string `json:"wav"` - Text string `json:"text"` - } `json:"cases"` - } - if err := json.Unmarshal(raw, &m); err != nil { - w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err) - } - for _, c := range m.Cases { - if c.Name == ref || c.WAV == ref { - return c.Text + if w.audio == nil { + raw, err := os.ReadFile(manifest) + if err != nil { + w.fatalf("audio step %q: reading %s: %v", ref, manifest, err) + return "" + } + var m struct { + Cases []struct { + Name string `json:"name"` + WAV string `json:"wav"` + Text string `json:"text"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &m); err != nil { + w.fatalf("audio step %q: parsing %s: %v", ref, manifest, err) + return "" + } + w.audio = make(map[string]string, len(m.Cases)*2) + for _, c := range m.Cases { + w.audio[c.Name] = c.Text + w.audio[c.WAV] = c.Text } } - w.t.Fatalf("audio step %q: no such case in %s", ref, manifest) + if text, ok := w.audio[ref]; ok && ref != "" { + return text + } + w.fatalf("audio step %q: no such case in %s", ref, manifest) return "" } @@ -582,35 +674,62 @@ func voicePTT() voice.PushToTalkReq { return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}} } -// callCount — how many requests every wired ecosystem fake has seen. -func (w *simWorld) callCount() int { - n := 0 - for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} { +// fakes — every ecosystem fake, in a fixed order. Both the mark and the paths +// walk this same order, which is the whole point: they have to agree. +func (w *simWorld) fakes() []*fakeServer { return []*fakeServer{w.praxis, w.nexus, w.hexis} } + +// callMark takes a PER-SERVER snapshot of how many requests each fake has +// seen. It is not a total. +// +// A total cannot be used to slice the concatenated path list, and the harness +// used to do exactly that. callPaths concatenates praxis, then nexus, then +// hexis; a total counts arrivals across all three. With praxis on 3 requests +// and nexus on 1, the total is 4 and the list is [p1 p2 p3 n1]. A step that +// calls praxis once makes the list [p1 p2 p3 p4 n1], and paths[4:] is [n1]. +// The new praxis call sits at index 3 and is never looked at, so +// expect_not_called on praxis passed on a step that called praxis. The same +// slice reported the stale nexus call as new, so expect_not_called on +// "/resolve" failed on a step that resolved nothing. +func (w *simWorld) callMark() []int { + mark := make([]int, len(w.fakes())) + for i, fs := range w.fakes() { if fs != nil { - n += len(fs.Requests()) + mark[i] = len(fs.Requests()) } } - return n + return mark } -func (w *simWorld) callPaths() []string { +// callPathsSince returns the calls each fake took after its own mark. A nil +// mark means "everything, from the beginning of the run". +func (w *simWorld) callPathsSince(mark []int) []string { var out []string - for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} { + for i, fs := range w.fakes() { if fs == nil { continue } - for _, r := range fs.Requests() { + reqs := fs.Requests() + from := 0 + if mark != nil && i < len(mark) { + from = mark[i] + } + if from > len(reqs) { + from = len(reqs) + } + for _, r := range reqs[from:] { out = append(out, r.Method+" "+r.Path) } } return out } +func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) } + // --------------------------------------------------------------------------- // Assertions // --------------------------------------------------------------------------- -func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) { +func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) { w.t.Helper() where := fmt.Sprintf("step %d (%s)", i+1, s.At) if s.Note != "" { @@ -654,9 +773,10 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore fail("no ecosystem call matches %q; calls so far: %v", want, paths) } } + since := w.callPathsSince(callsBefore) for _, unwanted := range s.ExpectNotCalled { - if anyContains(paths[callsBefore:], unwanted) { - fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:]) + if anyContains(since, unwanted) { + fail("an ecosystem call matched %q and must not have: %v", unwanted, since) } } @@ -666,8 +786,12 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore fail("no intake event matches %q; journal: %v", want, eventLines(evs)) } } - if s.ExpectNoEvents && w.bus.Len() > eventsBefore { - fail("expected nothing to arrive, journal grew to %d", w.bus.Len()) + // Counted publishes, not bus.Len(): the ring saturates at its capacity, so + // a long scenario that filled it made every later expect_no_events pass + // unconditionally. + if s.ExpectNoEvents && w.publishCount() > eventsBefore { + fail("expected nothing to arrive, %d event(s) were published", + w.publishCount()-eventsBefore) } } @@ -682,7 +806,10 @@ func sendableTexts(sends []delivery.Sendable) []string { func eventLines(evs []event.Event) []string { out := make([]string, 0, len(evs)) for _, e := range evs { - out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body)) + // Priority is in the line so a scenario can assert on it. It is the one + // field factPriority derives from confidence, and without it a replay + // could set a confidence but never see what the journal did with it. + out = append(out, fmt.Sprintf("%s/%s pri=%s %s %s", e.Source, e.Kind, e.Priority, e.Title, e.Body)) } return out } @@ -773,26 +900,143 @@ func TestSimulatorIsDeterministic(t *testing.T) { if first != second { t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second) } - // And the transcript's own timestamps must be the scenario's, not today's. - if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) { - t.Error("transcript carries the wall clock — something in the replay path read time.Now()") + // And every transcript timestamp must lie inside the scenario's own span. + // + // This used to compare the transcript against time.Now().Format("15:04"), + // which failed whenever the suite happened to run during the half hour the + // scenario covers: morning_missed logs 08:30 through 09:00, sc.Start + // contains only 08:30, so a run at 08:35 reported a wall-clock read that + // had not happened. A determinism test that depends on the time of day is + // the bug it is looking for. + start, err := time.Parse(time.RFC3339, sc.Start) + if err != nil { + t.Fatalf("bad start: %v", err) } + last := start + for _, s := range sc.Steps { + if at := stepInstant(t, start, s.At); at.After(last) { + last = at + } + } + // Only the lines logf stamped. A note or a feed item can carry its own + // newlines, and those continuation lines have no timestamp. + stamp := regexp.MustCompile(`^(\d\d:\d\d:\d\d) `) + for _, line := range strings.Split(first, "\n") { + m := stamp.FindStringSubmatch(line) + if m == nil { + continue + } + at := stepInstant(t, start, m[1]) + if at.Before(start) || at.After(last) { + t.Errorf("transcript line %q is stamped outside the scenario span %s..%s — "+ + "something in the replay path read time.Now()", + line, start.Format("15:04:05"), last.Format("15:04:05")) + } + } +} + +// TestCallsSinceAreScopedPerServer pins the ordering bug that made +// expect_not_called unsound. The mark is per server; a total cannot slice a +// list that is concatenated per server. +func TestCallsSinceAreScopedPerServer(t *testing.T) { + sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00", + Praxis: fixturePraxisAttentionItems(), Nexus: fixtureNexusResolved("ent_1", "thing", "device"), + Steps: []step{{At: "08:30"}}} + w := newSimWorld(t, sc) + + hit := func(fs *fakeServer, path string) { + t.Helper() + resp, err := http.Get(fs.URL + path) + if err != nil { + t.Fatalf("hitting %s: %v", path, err) + } + resp.Body.Close() + } + // Praxis runs ahead of nexus, so the concatenated list already has a nexus + // call sitting after three praxis ones. + hit(w.praxis, "/api/v1/tools/attention") + hit(w.praxis, "/api/v1/tools/attention") + hit(w.praxis, "/api/v1/tools/attention") + hit(w.nexus, "/api/v1/resolve") + + mark := w.callMark() + hit(w.praxis, "/api/v1/tools/attention") + + since := w.callPathsSince(mark) + if !anyContains(since, "attention") { + t.Errorf("the praxis call made after the mark is missing from %v", since) + } + if anyContains(since, "resolve") { + t.Errorf("a nexus call from before the mark was reported as new: %v", since) + } +} + +// TestPublishCountDoesNotSaturateWithTheRing pins expect_no_events on a bus +// that has already wrapped. bus.Len() stops at the capacity, so it can no +// longer answer "did anything arrive". +func TestPublishCountDoesNotSaturateWithTheRing(t *testing.T) { + sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00", + Steps: []step{{At: "08:30"}}} + w := newSimWorld(t, sc) + + for i := 0; i < event.DefaultCapacity+5; i++ { + w.bus.Publish(event.Event{ + Source: "sim:test", Kind: event.KindFact, Title: fmt.Sprintf("f%d", i), + }, w.clock.Now()) + } + if got := w.bus.Len(); got != event.DefaultCapacity { + t.Fatalf("ring holds %d, expected it to be saturated at %d", got, event.DefaultCapacity) + } + before := w.publishCount() + w.bus.Publish(event.Event{Source: "sim:test", Kind: event.KindFact, Title: "one more"}, w.clock.Now()) + if w.publishCount() != before+1 { + t.Errorf("publish count went %d → %d on a full ring, expected it to keep counting", + before, w.publishCount()) + } +} + +// stepInstant resolves "HH:MM" or "HH:MM:SS" against the scenario's start day. +func stepInstant(t *testing.T, start time.Time, at string) time.Time { + t.Helper() + layout := "15:04" + if strings.Count(at, ":") == 2 { + layout = "15:04:05" + } + hm, err := time.Parse(layout, at) + if err != nil { + t.Fatalf("bad step time %q: %v", at, err) + } + return time.Date(start.Year(), start.Month(), start.Day(), + hm.Hour(), hm.Minute(), hm.Second(), 0, start.Location()) } // TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake // that would silently produce a meaningless run. +// +// It used to advance to 09:00 and then to 09:30 and assert the clock had +// moved, which is the forwards case: the backwards branch it is named after +// was never reached, because reaching it ended the test. The fatalf seam is +// what makes it testable. func TestSimulatorRefusesBackwardsSteps(t *testing.T) { - // Not table-driven through run() because advanceTo calls t.Fatalf; this - // checks the ordering arithmetic directly. sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00", Steps: []step{{At: "09:00"}}} w := newSimWorld(t, sc) + var refusal string + w.fatalf = func(format string, args ...any) { refusal = fmt.Sprintf(format, args...) } + w.advanceTo("09:00") if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" { t.Fatalf("clock at %s after advancing to 09:00", got) } - w.advanceTo("09:30") - if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" { - t.Fatalf("clock at %s after advancing to 09:30", got) + if refusal != "" { + t.Fatalf("a forwards step was refused: %s", refusal) + } + + w.advanceTo("08:45") + if refusal == "" { + t.Fatal("a step going backwards to 08:45 was accepted") + } + if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" { + t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got) } } diff --git a/cmd/mavend/testdata/scenarios/act_degraded.json b/cmd/mavend/testdata/scenarios/act_degraded.json new file mode 100644 index 0000000..e500b45 --- /dev/null +++ b/cmd/mavend/testdata/scenarios/act_degraded.json @@ -0,0 +1,58 @@ +{ + "schema_version": 1, + "name": "act_degraded", + "description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.", + "start": "2026-08-01T09:00:00+03:00", + "praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]", + "tools": [{ "name": "list_attention" }], + "script": [ + { + "match": "требует внимания", + "route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]" + }, + { + "match": "", + "route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]", + "reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}" + } + ], + "steps": [ + { + "at": "09:00", + "note": "a healthy act reaches Praxis and speaks what it found", + "say": "что требует внимания?", + "expect_reply_contains": ["medicine not taken"], + "expect_called": ["/api/v1/tools/attention"], + "expect_no_send": true + }, + { + "at": "09:05", + "note": "the ecosystem goes down", + "fault": 503 + }, + { + "at": "09:10", + "note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.", + "say": "что требует внимания?", + "expect_reply_contains": ["не могу сейчас узнать"], + "expect_reply_lacks": ["medicine not taken"], + "expect_no_send": true + }, + { + "at": "09:15", + "note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis", + "tick": true, + "expect_not_called": ["/api/v1"], + "expect_no_send": true, + "expect_no_events": true + }, + { + "at": "09:20", + "note": "recovery: the same act works again, so the degraded turn left no sticky state", + "clear_fault": true, + "say": "что требует внимания?", + "expect_reply_contains": ["medicine not taken"], + "expect_no_send": true + } + ] +} diff --git a/cmd/mavend/testdata/scenarios/evening_degraded.json b/cmd/mavend/testdata/scenarios/evening_degraded.json index 92bbbb0..5d55974 100644 --- a/cmd/mavend/testdata/scenarios/evening_degraded.json +++ b/cmd/mavend/testdata/scenarios/evening_degraded.json @@ -25,7 +25,7 @@ "note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.", "audio": "ru_fact", "expect_reply_contains": ["записала"], - "expect_reply_lacks": ["записал,", "милый", "ваш"], + "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"], "expect_events": ["water"] }, { diff --git a/cmd/mavend/testdata/scenarios/morning_missed.json b/cmd/mavend/testdata/scenarios/morning_missed.json index af01fad..f398039 100644 --- a/cmd/mavend/testdata/scenarios/morning_missed.json +++ b/cmd/mavend/testdata/scenarios/morning_missed.json @@ -54,15 +54,16 @@ }, { "at": "08:40", - "note": "the work calendar signal — a relayed notification, below full confidence", + "note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.", "arrive": { "source": "ambient:notif", "fact": { "key": "calendar_event_20260801_планёрка", - "value": "10:00-11:00 планёрка" + "value": "10:00-11:00 планёрка", + "confidence": 0.6 } }, - "expect_events": ["ambient:notif", "планёрка"], + "expect_events": ["планёрка", "ambient:notif/fact pri=low"], "expect_no_send": true }, { @@ -73,17 +74,17 @@ }, { "at": "08:50", - "note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.", + "note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.", "say": "что я пропустил?", "expect_reply_contains": ["не знаю"], "expect_reply_lacks": ["рад ", "милый", "ваш"] }, { "at": "08:55", - "note": "stating a fact writes it and says so, in the feminine", + "note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".", "say": "я выпил воды", "expect_reply_contains": ["записала"], - "expect_reply_lacks": ["записал,", "милый"], + "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"], "expect_events": ["water"] }, {
whensourcekindpriwhatdetail
noticedhappenedsourcekindpriwhatdetail
{{.OccurredAt.Format "02.01 15:04:05"}}{{.NoticedAt.Format "02.01 15:04:05"}}{{.OccurredAt.Format "02.01 15:04:05"}} {{.Source}} {{.Kind}} {{.Priority}}