package main import ( "context" "errors" "log" "net" "sync" "time" "github.com/kami/maven/internal/event" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/store" ) // The two boot paths meet here. run() wires the daemon twice: once at boot // when a key is in the environment, and once inside UnlockFn after a passkey // assertion, minutes or days later. Listing the same wiring in both places is // what let them drift — seven workers started untracked on the unlock path and // two daemonAPI fields were never set there, silently, for as long as anyone // had been cold-starting (V-639). // // So both paths call newDaemonAPI and startBackground and nothing else. A // field or a worker added later reaches both paths or neither. // bootDeps is everything the two constructors below read. It is filled from // the same variables on both paths, by depsNow in run(). type bootDeps struct { coreFor func() ipc.CoreAPI tl *tickLoop evBus *event.Bus voiceW *voiceWiring st *store.Store factWorker *factEnrichmentWorker evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default) feedWkr *feedWorker // nil ⇒ no feed is read (the default) crawlWkr *crawlWorker // nil ⇒ no page is watched (the default) } // newDaemonAPI builds the real CoreAPI, with every field set. The unlock path // used to leave nexus and getMCPServers nil, so after a cold start // ResolveEntity refused with a nexus block configured and /tools rendered // "not configured" with an mcp block configured. Empty is a wrong answer // there, not a degraded one. func newDaemonAPI(d bootDeps) *daemonAPI { api := &daemonAPI{ CoreAPI: d.coreFor(), getTrace: d.tl.trace, getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return d.tl.morningStatus(ctx, time.Now()) }, getDayPlan: func(ctx context.Context) ipc.DayPlan { return d.tl.dayPlan(ctx, time.Now()) }, getEvents: intakeEventsFn(d.evBus), getDecisions: turnDecisionsFn(d.voiceW), seedStore: seedStoreIfAllowed(d.st), nexus: nexusOf(d.voiceW), } if d.voiceW != nil && d.voiceW.handler != nil { api.chatFn = d.voiceW.handler.handleText // And the reverse: the handler was wired with the bare store adapter, // which cannot serve the day plan. See upgradeAPI. d.voiceW.handler.upgradeAPI(api) } if d.voiceW != nil && d.voiceW.mcp != nil { api.getMCPServers = d.voiceW.mcp.status } return api } // namedWorker is one long-running goroutine. The name exists so the set is // assertable from a test and readable in a log; nothing dispatches on it. type namedWorker struct { name string run func(ctx context.Context) } // backgroundWorkers lists what this deployment runs. It is pure — it starts // nothing — so a test can compare the set the two paths would start without // standing a daemon up. func backgroundWorkers(d bootDeps) []namedWorker { var ws []namedWorker if d.voiceW != nil && d.voiceW.server != nil { ws = append(ws, namedWorker{"voice", func(context.Context) { if err := d.voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) { log.Printf("voice serve: %v", err) } }}) } ws = append(ws, namedWorker{"tick", d.tl.run}, namedWorker{"fact-enrichment", d.factWorker.run}, ) if d.evalWorker != nil { ws = append(ws, namedWorker{"memory-eval", d.evalWorker.run}) } if d.feedWkr != nil { ws = append(ws, namedWorker{"feed", d.feedWkr.run}) } if d.crawlWkr != nil { ws = append(ws, namedWorker{"crawl", d.crawlWkr.run}) } if d.voiceW != nil && d.voiceW.mcp != nil { ws = append(ws, namedWorker{"mcp", d.voiceW.mcp.run}) } if d.voiceW != nil && d.voiceW.home != nil { ws = append(ws, namedWorker{"home", d.voiceW.home.run}) } return ws } // startBackground starts every worker through goWorker, so waitWorkers can // wait for it at shutdown. A worker started as a bare `go func()` is the // shutdown bug documented at the end of run(): run() never returns, the // deferred Close never seals the database, and the ciphertext goes stale. func startBackground(ctx context.Context, wg *sync.WaitGroup, d bootDeps) { for _, w := range backgroundWorkers(d) { goWorker(wg, func() { w.run(ctx) }) } if d.voiceW != nil && d.voiceW.server != nil { log.Printf("mavend: voice listening on %s", d.voiceW.server.Addr()) } }