Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

497 lines
18 KiB
Go

// ABOUTME: after-sync embedding scheduler and the daemon's vector subsystem
// ABOUTME: wiring — index open, encoder/Manager construction, searcher adapter.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"time"
kitvec "go.kenn.io/kit/vector"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/server"
"go.kenn.io/agentsview/internal/sync"
"go.kenn.io/agentsview/internal/vector"
)
// vectorsWriteLockRetryInterval and vectorsWriteLockRetryTimeout bound how
// long setupVectorServing waits for vectors.write.lock before giving up and
// disabling vector serving for this daemon run. Package vars so tests can
// shrink them.
var (
vectorsWriteLockRetryInterval = 200 * time.Millisecond
vectorsWriteLockRetryTimeout = 5 * time.Second
)
// acquireVectorsWriteLockWithRetry tries to acquire vectors.write.lock,
// retrying briefly if another process (typically a long-running direct
// `embeddings build`) currently holds it. It returns ok=false, err=nil — not
// an error — once the retry window elapses with the lock still held, so
// setupVectorServing can degrade (disable vector serving for this run)
// rather than fail daemon startup: a long direct build must never block the
// daemon from booting.
func acquireVectorsWriteLockWithRetry(
ctx context.Context, dataDir string,
) (*writeOwnerLock, bool, error) {
deadline := time.Now().Add(vectorsWriteLockRetryTimeout)
for {
lock, err := tryAcquireNamedLock(dataDir, vectorsWriteLockFile)
if err == nil {
return lock, true, nil
}
var held writeOwnerLockHeldError
if !errors.As(err, &held) {
return nil, false, err
}
if !time.Now().Before(deadline) {
return nil, false, nil
}
select {
case <-ctx.Done():
return nil, false, nil
case <-time.After(vectorsWriteLockRetryInterval):
}
}
}
// embedDebounceInterval is the fixed quiet period the after-sync scheduler
// waits, after the last sync-completion signal, before running a build.
const embedDebounceInterval = 30 * time.Second
// embedManager is the subset of *vector.Manager the scheduler needs,
// letting tests substitute a fake that records TryBuild calls instead of
// driving a real build.
type embedManager interface {
TryBuild(ctx context.Context, req vector.BuildRequest) (bool, error)
}
// embedScheduler debounces sync-completion signals into background
// embedding builds: a burst of Notify calls collapses into one TryBuild
// after debounce has elapsed with no further signal, and a backstop ticker
// periodically forces a full mirror reconciliation regardless of sync
// activity.
type embedScheduler struct {
mgr embedManager
debounce time.Duration
backstop time.Duration
// includeAutomated is the configured [vector].include_automated scope,
// carried into every scheduler-driven BuildRequest so scheduled builds
// stay config-authoritative rather than drifting from a CLI-only
// override (see EmbeddingsBuildOptions.IncludeAutomatedSet).
includeAutomated bool
dirty chan struct{}
stop chan struct{}
done chan struct{}
}
// newEmbedScheduler builds a scheduler over mgr. backstop <= 0 disables the
// periodic backstop ticker entirely, leaving only the after-sync debounce
// path. includeAutomated is the configured [vector].include_automated scope
// applied to every build this scheduler triggers.
func newEmbedScheduler(mgr embedManager, debounce, backstop time.Duration, includeAutomated bool) *embedScheduler {
return &embedScheduler{
mgr: mgr,
debounce: debounce,
backstop: backstop,
includeAutomated: includeAutomated,
dirty: make(chan struct{}, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Notify signals that new data may need embedding. It never blocks: dirty
// has capacity 1, so a burst of calls while Run is busy (or not yet
// started) coalesces into a single pending signal.
func (s *embedScheduler) Notify() {
select {
case s.dirty <- struct{}{}:
default:
}
}
// Stop signals Run to exit and blocks until it has, so a caller that
// closes the underlying Index right after Stop can never race a build
// still in flight.
func (s *embedScheduler) Stop() {
close(s.stop)
<-s.done
}
// Run is the scheduler's goroutine body: it debounces Notify signals into
// TryBuild calls and, independently, fires a Backstop TryBuild on every
// backstop tick. It returns when ctx is done or Stop is called.
func (s *embedScheduler) Run(ctx context.Context) {
defer close(s.done)
debounceTimer := time.NewTimer(s.debounce)
stopTimer(debounceTimer)
defer debounceTimer.Stop()
var backstopC <-chan time.Time
if s.backstop > 0 {
ticker := time.NewTicker(s.backstop)
defer ticker.Stop()
backstopC = ticker.C
}
// pendingBackstop remembers a backstop tick that collided with a build
// already running elsewhere (a long manual `embeddings build`, or the
// HTTP API) and so was dropped: without it, that reconciliation pass
// would be silently deferred until the next backstop tick (24h by
// default) instead of running as soon as any build slot frees up. It
// is read and written only from this single goroutine, so it needs no
// synchronization of its own.
var pendingBackstop bool
for {
select {
case <-ctx.Done():
return
case <-s.stop:
return
case <-s.dirty:
resetTimer(debounceTimer, s.debounce)
case <-debounceTimer.C:
req := vector.BuildRequest{Backstop: pendingBackstop, IncludeAutomated: s.includeAutomated}
started, err := s.mgr.TryBuild(ctx, req)
if err != nil {
log.Printf("embed scheduler: build failed: %v", err)
}
if !started {
// A build was already running elsewhere; re-arm rather
// than drop the pass entirely. pendingBackstop, if set,
// stays set so the retry still carries it.
resetTimer(debounceTimer, s.debounce)
continue
}
// Only clear a carried backstop once the build both started and
// succeeded: a started-but-failed build (started=true, err!=nil)
// never actually ran the full reconciliation it carried, so
// clearing pendingBackstop here would silently defer that
// reconciliation to the next backstop tick (24h by default)
// instead of retrying on the very next debounced build.
if err == nil {
pendingBackstop = false
}
case <-backstopC:
started, err := s.mgr.TryBuild(ctx,
vector.BuildRequest{Backstop: true, IncludeAutomated: s.includeAutomated})
if err != nil {
log.Printf("embed scheduler: backstop build failed: %v", err)
}
// Same started-but-failed rule as the debounced path above:
// a build that started but errored never completed its
// reconciliation, so the pass must still be retried rather
// than deferred to the next backstop tick.
pendingBackstop = !started || err != nil
}
}
}
// stopTimer stops t, draining an already-fired-but-unread channel value so
// a following Reset starts from a clean state.
func stopTimer(t *time.Timer) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
}
// resetTimer stops and drains t before rearming it for d, the safe
// stop-then-reset sequence for a timer whose channel may already hold an
// unread tick.
func resetTimer(t *time.Timer, d time.Duration) {
stopTimer(t)
t.Reset(d)
}
// teeEmitter fans a sync completion out to the production SSE emitter and,
// when after-sync embedding is enabled, the embed scheduler. The scheduler
// side never blocks (embedScheduler.Notify is non-blocking), so wrapping
// the emitter this way cannot slow down the sync pipeline.
type teeEmitter struct {
primary sync.Emitter
scheduler *embedScheduler
runAfterSync bool
}
func (t teeEmitter) Emit(scope string) {
t.primary.Emit(scope)
if t.runAfterSync {
t.scheduler.Notify()
}
}
// searcherAdapter implements db.VectorSearcher over a vector.Index,
// translating its error taxonomy into db.ErrSemanticUnavailable-wrapped
// errors and enforcing the config-drift staleness gate before every query.
type searcherAdapter struct {
ix *vector.Index
enc kitvec.EncodeFunc
fingerprint string
}
// newSearcherAdapter builds a searcherAdapter for gen's configured
// embedding identity.
func newSearcherAdapter(ix *vector.Index, enc kitvec.EncodeFunc, gen kitvec.Generation) searcherAdapter {
return searcherAdapter{ix: ix, enc: enc, fingerprint: gen.Fingerprint()}
}
// SemanticSearch implements db.VectorSearcher. A stale active generation
// (the configured model/dimension no longer matches what was last built)
// is a hard error checked before querying at all, rather than silently
// searching the mismatched old generation.
func (a searcherAdapter) SemanticSearch(
ctx context.Context, query string, limit int,
) ([]db.VectorHit, error) {
stale, err := a.ix.StaleActive(ctx, a.fingerprint)
if err != nil {
// StaleActive shares Search's error taxonomy (notably
// vector.ErrMirrorVersionMismatch from a version-mismatched
// read-only vectors.db), so it is translated the same way;
// errors outside the taxonomy pass through with this context.
return nil, translateSearchError(
fmt.Errorf("checking embedding index staleness: %w", err))
}
if stale {
return nil, fmt.Errorf(
"%w: index is stale (embedding config changed): run "+
"'agentsview embeddings build --full-rebuild'",
db.ErrSemanticUnavailable)
}
hits, err := a.ix.Search(ctx, a.enc, query, limit)
if err != nil {
return nil, translateSearchError(err)
}
out := make([]db.VectorHit, len(hits))
for i, h := range hits {
out[i] = db.VectorHit{
SessionID: h.SessionID,
Ordinal: h.Ordinal,
OrdinalStart: h.OrdinalStart,
OrdinalEnd: h.OrdinalEnd,
Subordinate: h.Subordinate,
Score: h.Score,
Snippet: h.Snippet,
}
}
return out, nil
}
// ResolveMessageUnits implements db.VectorSearcher by delegating to the
// index's resolver, translating its error taxonomy (notably
// vector.ErrMirrorVersionMismatch from a version-mismatched read-only
// vectors.db) the same way SemanticSearch does. It needs no staleness gate
// of its own: the hybrid path always calls SemanticSearch — which enforces
// the gate — before resolving FTS hits.
func (a searcherAdapter) ResolveMessageUnits(
ctx context.Context, refs []db.MessageRef,
) ([]db.UnitRef, error) {
units, err := a.ix.ResolveMessageUnits(ctx, refs)
if err != nil {
return nil, translateSearchError(err)
}
return units, nil
}
// translateSearchError maps vector.Index.Search's error taxonomy to
// server-facing sentinels. ErrNoActiveGeneration and BuildingError both
// mean nothing is queryable yet, so they map to db.ErrSemanticUnavailable
// (ErrNoActiveGeneration needs no extra cause text: db.ErrSemanticUnavailable's
// own message already is the "run the build" remediation).
// ErrMirrorVersionMismatch (a read-only vectors.db written by an
// incompatible mirror schema version) also maps to
// db.ErrSemanticUnavailable, carrying the sentinel's rebuild-required
// message as the cause. A QueryEncodeError means the index itself is ready
// but this particular query-time embed call failed (the embeddings endpoint
// is down, slow, or erroring); that maps to the distinct
// db.ErrSemanticTransient so a caller can tell "not configured" apart from
// "configured, but this request failed and can be retried".
func translateSearchError(err error) error {
var buildingErr *vector.BuildingError
var queryEncErr *vector.QueryEncodeError
switch {
case errors.As(err, &buildingErr):
return fmt.Errorf("%w: index is building: %d%% complete",
db.ErrSemanticUnavailable, buildingErr.Percent)
case errors.Is(err, vector.ErrMirrorVersionMismatch):
return fmt.Errorf("%w: %v", db.ErrSemanticUnavailable, err)
case errors.Is(err, vector.ErrNoActiveGeneration):
return db.ErrSemanticUnavailable
case errors.As(err, &queryEncErr):
// Double-wrap so callers can still match the underlying cause —
// notably context.Canceled/DeadlineExceeded from a dead client —
// alongside the transient sentinel.
return fmt.Errorf("%w: %w", db.ErrSemanticTransient, queryEncErr.Err)
default:
return err
}
}
// vectorServing bundles what runServe needs to wire the vector subsystem
// into the daemon. All fields are zero when [vector] is disabled, so
// callers can treat it uniformly without a separate enabled check.
type vectorServing struct {
ServerOpts []server.Option
Scheduler *embedScheduler
Close func() error
}
// setupVectorServing acquires vectors.write.lock, opens vectors.db
// read-write, builds the embeddings encoder and Manager, wires database's
// semantic searcher, and constructs the after-sync scheduler. database is
// passed directly as the Manager's UnitSource since *db.DB already
// implements vector.UnitSource.
//
// The write lock is held for the daemon's lifetime (released by the
// returned Close) so a concurrent direct `embeddings build` cannot race the
// daemon's own builds over vectors.db — both writers park evicted rows at
// sentinel ordinals, and a race between them can trip unique-index
// conflicts or silently discard embeddings. If the lock is already held
// (typically by a long-running direct build), setupVectorServing retries
// briefly and, failing that, disables vector serving for this run — logging
// a warning — rather than blocking or failing daemon startup.
func setupVectorServing(
ctx context.Context, cfg config.Config, database *db.DB,
) (vectorServing, error) {
if !cfg.Vector.Enabled {
return vectorServing{}, nil
}
lock, ok, err := acquireVectorsWriteLockWithRetry(ctx, cfg.DataDir)
if err != nil {
return vectorServing{}, fmt.Errorf("acquiring vectors write lock: %w", err)
}
if !ok {
log.Printf(
"serve: vectors.write.lock held by another process after %s; "+
"disabling vector serving for this run",
vectorsWriteLockRetryTimeout,
)
// The 501s this leaves behind must say why and how to recover: the
// generic "embeddings manager not available" reads like a missing
// feature, when the actual fix is restarting the daemon once the
// lock-holding process (typically a direct build) exits.
return vectorServing{ServerOpts: []server.Option{
server.WithEmbeddingsUnavailableReason(
"vector serving is disabled for this daemon run: another " +
"process (typically a direct 'agentsview embeddings build') " +
"held vectors.write.lock at startup; wait for it to finish, " +
"then restart the daemon"),
}}, nil
}
ix, err := vector.Open(
ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false, cfg.Vector.Embeddings.MaxInputChars,
)
if err != nil {
_ = lock.Close()
return vectorServing{}, fmt.Errorf("opening vectors.db: %w", err)
}
encoders, err := vectorEncoderSet(cfg.Vector.Embeddings)
if err != nil {
ix.Close()
_ = lock.Close()
return vectorServing{}, err
}
// Search-time query encoding always uses the default server; builds may
// pick any named entry via BuildRequest.Using.
queryEnc := encoders.ByName[encoders.Default].Encode
backstop, err := time.ParseDuration(cfg.Vector.Embed.BackstopInterval)
if err != nil {
ix.Close()
_ = lock.Close()
return vectorServing{}, fmt.Errorf(
"parsing [vector.embed] backstop_interval %q: %w",
cfg.Vector.Embed.BackstopInterval, err)
}
gen := vectorGeneration(cfg.Vector.Embeddings)
mgr := vector.NewManager(ix, database, encoders, gen)
database.SetVectorSearcher(newSearcherAdapter(ix, queryEnc, gen))
scheduler := newEmbedScheduler(mgr, embedDebounceInterval, backstop, cfg.Vector.IncludeAutomated)
return vectorServing{
ServerOpts: []server.Option{server.WithEmbeddingsManager(mgr)},
Scheduler: scheduler,
Close: func() error {
ixErr := ix.Close()
lockErr := lock.Close()
if ixErr != nil {
return ixErr
}
return lockErr
},
}, nil
}
// installDirectVectorSearcher wires a read-only vectors.db into d's
// semantic searcher for direct (non-daemon) CLI reads, e.g. `session
// search --semantic` with no daemon running. It is a no-op — leaving d
// without a VectorSearcher, so callers see db.ErrSemanticUnavailable
// naturally — when [vector] is disabled, vectors.db does not exist yet, or
// vectors.db cannot be opened at all (e.g. corrupt or truncated).
//
// A vectors.db written by an incompatible mirror schema version is NOT one
// of those no-op cases: the read-only open succeeds with the mismatch
// recorded on the Index, so the searcher is wired and every semantic query
// surfaces the rebuild-required error (vector.ErrMirrorVersionMismatch,
// mapped by translateSearchError onto db.ErrSemanticUnavailable with the
// remediation attached) instead of semantic search silently reading as
// "not enabled".
//
// Vector wiring failures never fail direct service construction: every
// direct read command (e.g. `session list`) opens vectors.db eagerly
// through this path, so a bad vectors.db must not break unrelated reads
// against an otherwise-healthy sessions.db archive. Failures are logged as
// a warning and degrade to semantic search returning
// db.ErrSemanticUnavailable, matching the disabled/missing-file cases.
//
// The returned close func is nil whenever no searcher was wired (the
// no-op cases above, and the degraded-on-error case); otherwise the
// caller must call it when done with d to release the read-only index
// handle.
func installDirectVectorSearcher(cfg config.Config, d *db.DB) func() error {
if !cfg.Vector.Enabled {
return nil
}
path := cfg.Vector.ResolvedDBPath(cfg.DataDir)
if _, err := os.Stat(path); err != nil {
return nil
}
ix, err := vector.Open(context.Background(), path, true, cfg.Vector.Embeddings.MaxInputChars)
if err != nil {
log.Printf(
"warning: opening vectors.db for semantic search: %v; "+
"continuing without semantic search", err,
)
return nil
}
// Query encoding uses the default server.
enc, err := newVectorEncoder(cfg.Vector.Embeddings, "")
if err != nil {
ix.Close()
log.Printf(
"warning: building embeddings encoder for semantic search: %v; "+
"continuing without semantic search", err,
)
return nil
}
d.SetVectorSearcher(newSearcherAdapter(ix, enc, vectorGeneration(cfg.Vector.Embeddings)))
return ix.Close
}