mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
fix(sqlite): bootstrap not running on first chat due to per-connection PRAGMA gap
Root cause: pool.go applied busy_timeout PRAGMA via db.Exec() which only affects one connection in the pool. Other connections had no busy_timeout, causing immediate SQLITE_BUSY errors during concurrent startup operations (agent creation, WebSocket connect, health checks). This silently aborted context file seeding — BOOTSTRAP.md, USER.md, AGENTS.md all missing from system prompt on first interaction. Fix (3 layers): 1. pragmaConnector: wraps sql.Driver to apply PRAGMAs (busy_timeout, WAL, etc.) on every new connection, not just one. All SQLite queries benefit. 2. CacheInvalidateFunc: clears ContextFileInterceptor cache after seeding so LoadContextFiles sees newly seeded files on the first turn. 3. fallbackBootstrap: if DB seed still fails, injects embedded templates in-memory so the first turn still gets onboarding. Clears after use.
This commit is contained in:
1 parent
8beb72039c
commit
b088144fd9
8 files changed
+160
-21
No files matched your search
@@ -73,6 +73,18 @@ func buildBootstrapCleanup(as store.AgentStore) agent.BootstrapCleanupFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// buildCacheInvalidate creates a callback that invalidates the context file cache
|
||||
// for a user after SeedUserFiles writes via raw agentStore. Without this,
|
||||
// LoadContextFiles may return stale (empty) cached results on the first turn.
|
||||
func buildCacheInvalidate(intc *tools.ContextFileInterceptor) agent.CacheInvalidateFunc {
|
||||
if intc == nil {
|
||||
return nil
|
||||
}
|
||||
return func(agentID uuid.UUID, userID string) {
|
||||
intc.InvalidateUser(agentID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// buildContextFileLoader creates the per-request context file loader callback.
|
||||
// Delegates to the ContextFileInterceptor for type-aware routing.
|
||||
func buildContextFileLoader(intc *tools.ContextFileInterceptor) agent.ContextFileLoaderFunc {
|
||||
|
||||
@@ -144,6 +144,7 @@ func wireExtras(
|
||||
SeedUserFiles: seedUserFiles,
|
||||
ContextFileLoader: contextFileLoader,
|
||||
BootstrapCleanup: buildBootstrapCleanup(stores.Agents),
|
||||
CacheInvalidate: buildCacheInvalidate(contextFileInterceptor),
|
||||
InjectionAction: injectionAction,
|
||||
MaxMessageChars: appCfg.Gateway.MaxMessageChars,
|
||||
CompactionCfg: appCfg.Agents.Defaults.Compaction,
|
||||
|
||||
@@ -137,6 +137,17 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
var contextFiles []bootstrap.ContextFile
|
||||
if !lightContext {
|
||||
contextFiles = l.resolveContextFiles(ctx, userID)
|
||||
|
||||
// Fallback: if DB seeding failed (e.g. SQLITE_BUSY) but we have
|
||||
// in-memory embedded templates, merge them so the first turn still
|
||||
// gets bootstrap onboarding. Only applies when DB returned no user files.
|
||||
if val, ok := l.userSetups.Load(userID); ok {
|
||||
if fb := val.(*userSetup).fallbackBootstrap; len(fb) > 0 {
|
||||
contextFiles = l.mergeContextFallback(contextFiles, fb)
|
||||
// Clear after first use — next turn should read from DB.
|
||||
val.(*userSetup).fallbackBootstrap = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
hadBootstrap := false
|
||||
for _, cf := range contextFiles {
|
||||
@@ -318,6 +329,21 @@ func (l *Loop) resolveContextFiles(ctx context.Context, userID string) []bootstr
|
||||
return merged
|
||||
}
|
||||
|
||||
// mergeContextFallback adds fallback (in-memory) files into contextFiles,
|
||||
// skipping any that already exist. Used when DB seeding failed.
|
||||
func (l *Loop) mergeContextFallback(contextFiles, fallback []bootstrap.ContextFile) []bootstrap.ContextFile {
|
||||
existing := make(map[string]struct{}, len(contextFiles))
|
||||
for _, f := range contextFiles {
|
||||
existing[f.Path] = struct{}{}
|
||||
}
|
||||
for _, fb := range fallback {
|
||||
if _, ok := existing[fb.Path]; !ok {
|
||||
contextFiles = append(contextFiles, fb)
|
||||
}
|
||||
}
|
||||
return contextFiles
|
||||
}
|
||||
|
||||
// bootstrapToolAllowlist is the set of tools available during bootstrap onboarding.
|
||||
// Only write_file (and its alias Write) are needed to save USER.md and clear BOOTSTRAP.md.
|
||||
var bootstrapToolAllowlist = map[string]bool{
|
||||
|
||||
@@ -28,8 +28,9 @@ const bootstrapAutoCleanupTurns = 3
|
||||
// Consolidates workspace resolution and context file seeding into one struct
|
||||
// to prevent desync between the two concerns.
|
||||
type userSetup struct {
|
||||
workspace string // effective workspace from user_agent_profiles (expanded, absolute)
|
||||
seeded bool // whether SeedUserFiles has been called this instance
|
||||
workspace string // effective workspace from user_agent_profiles (expanded, absolute)
|
||||
seeded bool // whether SeedUserFiles has been called this instance
|
||||
fallbackBootstrap []bootstrap.ContextFile // in-memory fallback when DB seed fails (e.g. SQLITE_BUSY)
|
||||
}
|
||||
|
||||
// EnsureUserProfileFunc creates/resolves a user's profile and workspace.
|
||||
@@ -55,6 +56,11 @@ type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID,
|
||||
// Called automatically so the system doesn't rely on the LLM to delete it.
|
||||
type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID string) error
|
||||
|
||||
// CacheInvalidateFunc invalidates the context file cache for a user after seeding.
|
||||
// SeedUserFiles writes via raw agentStore (bypassing ContextFileInterceptor cache),
|
||||
// so this callback ensures LoadContextFiles sees the newly seeded files.
|
||||
type CacheInvalidateFunc func(agentID uuid.UUID, userID string)
|
||||
|
||||
// Loop is the agent execution loop for one agent instance.
|
||||
// Think → Act → Observe cycle with tool execution.
|
||||
type Loop struct {
|
||||
@@ -101,6 +107,7 @@ type Loop struct {
|
||||
ensureUserFiles EnsureUserFilesFunc // legacy combined callback (fallback)
|
||||
contextFileLoader ContextFileLoaderFunc
|
||||
bootstrapCleanup BootstrapCleanupFunc
|
||||
cacheInvalidate CacheInvalidateFunc // invalidate context file cache after seeding
|
||||
userSetups sync.Map // userID → *userSetup (workspace + seeding state, per Loop instance)
|
||||
|
||||
// Compaction config (memory flush settings)
|
||||
@@ -250,6 +257,7 @@ type LoopConfig struct {
|
||||
EnsureUserFiles EnsureUserFilesFunc // legacy: combined (used when above are nil)
|
||||
ContextFileLoader ContextFileLoaderFunc
|
||||
BootstrapCleanup BootstrapCleanupFunc
|
||||
CacheInvalidate CacheInvalidateFunc // invalidate context file cache after seeding
|
||||
|
||||
// Tracing collector (nil = no tracing)
|
||||
TraceCollector *tracing.Collector
|
||||
@@ -365,6 +373,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
ensureUserFiles: cfg.EnsureUserFiles,
|
||||
contextFileLoader: cfg.ContextFileLoader,
|
||||
bootstrapCleanup: cfg.BootstrapCleanup,
|
||||
cacheInvalidate: cfg.CacheInvalidate,
|
||||
compactionCfg: cfg.CompactionCfg,
|
||||
contextPruningCfg: cfg.ContextPruningCfg,
|
||||
sandboxEnabled: cfg.SandboxEnabled,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
@@ -91,6 +92,15 @@ func (l *Loop) getOrCreateUserSetup(ctx context.Context, userID, channel string,
|
||||
// Passes isNew so SeedUserFiles knows whether to skip existing files.
|
||||
if err := l.seedUserFiles(ctx, l.agentUUID, userID, l.agentType, isNew); err != nil {
|
||||
slog.Warn("failed to seed user context files", "error", err)
|
||||
// Seeding failed (e.g. SQLITE_BUSY after retries). Inject
|
||||
// embedded bootstrap templates in-memory so the first turn
|
||||
// still gets onboarding. DB seed will retry next session.
|
||||
setup.fallbackBootstrap = bootstrap.EmbeddedUserFiles(l.agentType)
|
||||
} else if l.cacheInvalidate != nil {
|
||||
// SeedUserFiles writes via raw agentStore, bypassing the
|
||||
// ContextFileInterceptor cache. Invalidate so LoadContextFiles
|
||||
// sees newly seeded BOOTSTRAP.md/USER.md on the first turn.
|
||||
l.cacheInvalidate(l.agentUUID, userID)
|
||||
}
|
||||
setup.seeded = true
|
||||
} else if l.ensureUserFiles != nil {
|
||||
|
||||
@@ -40,6 +40,7 @@ type ResolverDeps struct {
|
||||
SeedUserFiles SeedUserFilesFunc
|
||||
ContextFileLoader ContextFileLoaderFunc
|
||||
BootstrapCleanup BootstrapCleanupFunc
|
||||
CacheInvalidate CacheInvalidateFunc
|
||||
|
||||
// Security
|
||||
InjectionAction string // "log", "warn", "block", "off"
|
||||
@@ -370,6 +371,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
SeedUserFiles: deps.SeedUserFiles,
|
||||
ContextFileLoader: deps.ContextFileLoader,
|
||||
BootstrapCleanup: deps.BootstrapCleanup,
|
||||
CacheInvalidate: deps.CacheInvalidate,
|
||||
OnEvent: deps.OnEvent,
|
||||
TraceCollector: deps.TraceCollector,
|
||||
InjectionAction: deps.InjectionAction,
|
||||
|
||||
@@ -44,6 +44,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
|
||||
|
||||
existing, err := agentStore.GetAgentContextFiles(ctx, agentID)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap: failed to check existing agent files", "agent", agentID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -145,12 +146,14 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
|
||||
// Check existing per-user files to avoid overwriting personalized content
|
||||
existing, err := agentStore.GetUserContextFiles(ctx, agentID, userID)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap: failed to check existing user files", "agent", agentID, "user", userID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Early exit: user already has files → not a brand-new user.
|
||||
// Avoids re-seeding BOOTSTRAP.md after auto-cleanup on server restart.
|
||||
if skipIfAnyExist && len(existing) > 0 {
|
||||
slog.Debug("bootstrap: skip user seed (existing files)", "agent", agentID, "user", userID, "existing", len(existing))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -221,3 +224,26 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
|
||||
|
||||
return seeded, nil
|
||||
}
|
||||
|
||||
// EmbeddedUserFiles returns in-memory context files from embedded templates.
|
||||
// Used as a fallback when DB seeding fails (e.g. SQLITE_BUSY) so the first
|
||||
// turn still gets bootstrap onboarding without waiting for DB recovery.
|
||||
func EmbeddedUserFiles(agentType string) []ContextFile {
|
||||
files := userSeedFilesOpen
|
||||
if agentType == store.AgentTypePredefined {
|
||||
files = userSeedFilesPredefined
|
||||
}
|
||||
var result []ContextFile
|
||||
for _, name := range files {
|
||||
templateName := name
|
||||
if agentType == store.AgentTypePredefined && name == BootstrapFile {
|
||||
templateName = "BOOTSTRAP_PREDEFINED.md"
|
||||
}
|
||||
content, err := templateFS.ReadFile(filepath.Join("templates", templateName))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, ContextFile{Path: name, Content: string(content)})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -3,47 +3,89 @@
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver
|
||||
)
|
||||
|
||||
// connectionPragmas are applied to EVERY new SQLite connection.
|
||||
// Critical: PRAGMA settings are per-connection in SQLite. Using db.Exec()
|
||||
// only applies to ONE connection in the pool — other connections won't have
|
||||
// busy_timeout, causing immediate SQLITE_BUSY errors under concurrency.
|
||||
var connectionPragmas = []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA busy_timeout = 15000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA cache_size = -8000", // 8MB cache
|
||||
"PRAGMA foreign_keys = ON",
|
||||
}
|
||||
|
||||
// pragmaConnector wraps a sql.Driver to apply PRAGMAs on every new connection.
|
||||
// This ensures ALL connections in the pool have busy_timeout, WAL mode, etc.
|
||||
type pragmaConnector struct {
|
||||
driver driver.Driver
|
||||
dsn string
|
||||
pragmas []string
|
||||
}
|
||||
|
||||
func (c *pragmaConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
conn, err := c.driver.Open(c.dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply PRAGMAs to this specific connection.
|
||||
for _, p := range c.pragmas {
|
||||
if execer, ok := conn.(driver.ExecerContext); ok {
|
||||
if _, execErr := execer.ExecContext(ctx, p, nil); execErr != nil {
|
||||
slog.Warn("sqlite pragma failed on new conn", "pragma", p, "error", execErr)
|
||||
}
|
||||
} else if execer, ok := conn.(driver.Execer); ok { //nolint:staticcheck
|
||||
if _, execErr := execer.Exec(p, nil); execErr != nil {
|
||||
slog.Warn("sqlite pragma failed on new conn", "pragma", p, "error", execErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *pragmaConnector) Driver() driver.Driver { return c.driver }
|
||||
|
||||
// OpenDB opens a SQLite database at the given path with WAL mode and recommended pragmas.
|
||||
// Uses modernc.org/sqlite (pure Go, zero CGo).
|
||||
//
|
||||
// Desktop app concurrency model:
|
||||
// - WAL mode: allows concurrent readers alongside a single writer
|
||||
// - MaxOpenConns(2): limits contention; one reader + one writer can proceed in parallel
|
||||
// - busy_timeout=15000: writer retries for 15s before SQLITE_BUSY error
|
||||
// - busy_timeout=15000: ALL connections wait up to 15s before SQLITE_BUSY
|
||||
// - _txlock=immediate: write transactions acquire lock immediately (fail-fast on contention)
|
||||
//
|
||||
// PRAGMAs are applied per-connection via pragmaConnector, ensuring every
|
||||
// connection in the pool has consistent settings (busy_timeout, WAL, etc.).
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_txlock=immediate&_foreign_keys=ON", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
dsn := fmt.Sprintf("file:%s?_txlock=immediate", path)
|
||||
|
||||
// Get the registered driver to wrap with pragmaConnector.
|
||||
drv, err := getSQLiteDriver()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := sql.OpenDB(&pragmaConnector{
|
||||
driver: drv,
|
||||
dsn: dsn,
|
||||
pragmas: connectionPragmas,
|
||||
})
|
||||
|
||||
// SQLite is single-writer; WAL allows concurrent readers.
|
||||
// 4 connections: up to 3 readers + 1 writer can proceed in parallel,
|
||||
// reducing connection pool starvation during concurrent operations.
|
||||
db.SetMaxOpenConns(4)
|
||||
|
||||
// Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite.
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA busy_timeout = 15000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA cache_size = -8000", // 8MB cache
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
slog.Warn("sqlite pragma failed", "pragma", p, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify connection works.
|
||||
// Verify connection works (also triggers first pragma application).
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
@@ -51,3 +93,14 @@ func OpenDB(path string) (*sql.DB, error) {
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// getSQLiteDriver retrieves the registered "sqlite" driver instance.
|
||||
func getSQLiteDriver() (driver.Driver, error) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sqlite driver: %w", err)
|
||||
}
|
||||
drv := db.Driver()
|
||||
db.Close()
|
||||
return drv, nil
|
||||
}
|
||||
Reference in new issue
Block a user