73441bcc6c
run() wired the daemon twice and the lists drifted: seven workers started untracked on the unlock path, a shadowed WaitGroup hid the voice server, and nexus and getMCPServers were never set after a passkey unlock. newDaemonAPI and startBackground in cmd/mavend/boot.go are the one place both paths go through now. backgroundWorkers is the pure list behind the second, so the set is assertable without a running daemon.
121 lines
4.3 KiB
Go
121 lines
4.3 KiB
Go
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())
|
|
}
|
|
}
|