From 896c32882a96b61980b015e070c2fff00982e48a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 23:33:26 +0400 Subject: [PATCH] assert the two paths wire the same set (V-639) Without this it drifts again on the next wiring line: every daemonAPI field set on a fully wired deployment, and the worker set by name at the full set and at the floor. --- cmd/mavend/boot_test.go | 96 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 cmd/mavend/boot_test.go diff --git a/cmd/mavend/boot_test.go b/cmd/mavend/boot_test.go new file mode 100644 index 0000000..47276f6 --- /dev/null +++ b/cmd/mavend/boot_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/kami/maven/internal/decision" + "github.com/kami/maven/internal/event" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/voice" +) + +// fullDeps — a deployment with every optional piece present. Nothing here is +// run: newDaemonAPI takes method values and backgroundWorkers is pure, so +// zero-value wirings are enough to say what WOULD be started. +func fullDeps() bootDeps { + h := &reactiveHandler{ + ecosystem: &ecosystemWiring{nexus: &nexusClient{}}, + decisions: decision.NewRing(), + } + return bootDeps{ + coreFor: func() ipc.CoreAPI { return ipc.UnimplementedCoreAPI{} }, + tl: &tickLoop{}, + evBus: event.NewBus(4), + st: &store.Store{}, + factWorker: &factEnrichmentWorker{}, + evalWorker: &memoryEvalWorker{}, + feedWkr: &feedWorker{}, + crawlWkr: &crawlWorker{}, + voiceW: &voiceWiring{ + server: &voice.Server{}, + handler: h, + mcp: &mcpWiring{}, + home: &homeWiring{}, + }, + } +} + +// The unlock path used to build its own daemonAPI literal and leave nexus and +// getMCPServers nil (V-639). Both paths call newDaemonAPI now, so the drift +// that can still happen is a field added to the struct and not to the +// constructor. This catches that one, by name. +func TestNewDaemonAPISetsEveryField(t *testing.T) { + prev := allowSeedOnStart + allowSeedOnStart = true + defer func() { allowSeedOnStart = prev }() + + api := newDaemonAPI(fullDeps()) + v := reflect.ValueOf(*api) + for i := range v.NumField() { + if v.Field(i).IsZero() { + t.Errorf("newDaemonAPI left %s unset — a fully wired deployment must fill every field", v.Type().Field(i).Name) + } + } +} + +// The handler is wired with the bare store adapter and cannot serve the day +// plan until upgradeAPI hands it the real one. The unlocked path did that and +// the unlock path did it too; keep it a property of the constructor. +func TestNewDaemonAPIUpgradesTheHandler(t *testing.T) { + d := fullDeps() + api := newDaemonAPI(d) + if d.voiceW.handler.api != ipc.CoreAPI(api) { + t.Fatal("newDaemonAPI did not hand the handler the API it built") + } +} + +// Every worker the daemon runs goes through startBackground, so shutdown can +// wait for it. The unlock path used to start seven of these as bare +// `go func()` under a shadowed WaitGroup. +func TestBackgroundWorkersFullSet(t *testing.T) { + want := []string{"voice", "tick", "fact-enrichment", "memory-eval", "feed", "crawl", "mcp", "home"} + var got []string + for _, w := range backgroundWorkers(fullDeps()) { + got = append(got, w.name) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("workers = %v, want %v", got, want) + } +} + +// A default box configures none of the optional blocks. Two workers always run +// and the rest stay dark, rather than a nil run being scheduled. +func TestBackgroundWorkersFloor(t *testing.T) { + d := fullDeps() + d.evalWorker, d.feedWkr, d.crawlWkr, d.voiceW = nil, nil, nil, nil + want := []string{"tick", "fact-enrichment"} + var got []string + for _, w := range backgroundWorkers(d) { + got = append(got, w.name) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("workers = %v, want %v", got, want) + } +}