fix: resolve search, database, and docker bugs (#2079)
* fix: resolve search, database, and docker bugs (#1913, #1916, #1956, #1957, #2048) - Fix concept/concepts param mismatch in SearchManager.normalizeParams (#1916) - Add FTS5 keyword fallback when ChromaDB is unavailable (#1913, #2048) - Add periodic WAL checkpoint and journal_size_limit to prevent unbounded WAL growth (#1956) - Add periodic clearFailed() to purge stale pending_messages (#1957) - Fix nounset-safe TTY_ARGS expansion in docker/claude-mem/run.sh Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent silent data loss on non-XML responses, add queue info to /health (#1867, #1874) - ResponseProcessor: mark messages as failed (with retry) instead of confirming when the LLM returns non-XML garbage (auth errors, rate limits) (#1874) - Health endpoint: include activeSessions count for queue liveness monitoring (#1867) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: cache isFts5Available() at construction time Addresses Greptile review: avoid DDL probe (CREATE + DROP) on every text query. Result is now cached in _fts5Available at construction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,10 +33,15 @@ export class SessionSearch {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.run('PRAGMA journal_mode = WAL');
|
||||
|
||||
// Cache FTS5 availability once at construction (avoids DDL probe on every query)
|
||||
this._fts5Available = this.isFts5Available();
|
||||
|
||||
// Ensure FTS tables exist
|
||||
this.ensureFTSTables();
|
||||
}
|
||||
|
||||
private _fts5Available: boolean;
|
||||
|
||||
/**
|
||||
* Ensure FTS5 tables exist (backward compatibility only - no longer used for search)
|
||||
*
|
||||
@@ -307,9 +312,33 @@ export class SessionSearch {
|
||||
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
||||
}
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
// FTS5 keyword fallback when ChromaDB is unavailable (#1913, #2048)
|
||||
if (this._fts5Available) {
|
||||
const filterClause = this.buildFilterClause(filters, params, 'o');
|
||||
const orderClause = this.buildOrderClause(orderBy, true, 'observations_fts');
|
||||
|
||||
const sql = `
|
||||
SELECT o.*, o.discovery_tokens
|
||||
FROM observations o
|
||||
JOIN observations_fts ON observations_fts.rowid = o.id
|
||||
WHERE observations_fts MATCH ?
|
||||
${filterClause ? 'AND ' + filterClause : ''}
|
||||
${orderClause}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.unshift(query);
|
||||
params.push(limit, offset);
|
||||
|
||||
try {
|
||||
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
||||
} catch (error) {
|
||||
logger.warn('DB', 'FTS5 observation search failed, returning empty', {}, error instanceof Error ? error : undefined);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('DB', 'Text search unavailable: ChromaDB disabled and FTS5 not available');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -346,9 +375,38 @@ export class SessionSearch {
|
||||
return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[];
|
||||
}
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
// FTS5 keyword fallback when ChromaDB is unavailable (#1913, #2048)
|
||||
if (this._fts5Available) {
|
||||
const filterOptions = { ...filters };
|
||||
delete filterOptions.type;
|
||||
const filterClause = this.buildFilterClause(filterOptions, params, 's');
|
||||
|
||||
const orderClause = orderBy === 'date_asc'
|
||||
? 'ORDER BY s.created_at_epoch ASC'
|
||||
: 'ORDER BY session_summaries_fts.rank ASC';
|
||||
|
||||
const sql = `
|
||||
SELECT s.*, s.discovery_tokens
|
||||
FROM session_summaries s
|
||||
JOIN session_summaries_fts ON session_summaries_fts.rowid = s.id
|
||||
WHERE session_summaries_fts MATCH ?
|
||||
${filterClause ? 'AND ' + filterClause : ''}
|
||||
${orderClause}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.unshift(query);
|
||||
params.push(limit, offset);
|
||||
|
||||
try {
|
||||
return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[];
|
||||
} catch (error) {
|
||||
logger.warn('DB', 'FTS5 session search failed, returning empty', {}, error instanceof Error ? error : undefined);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('DB', 'Text search unavailable: ChromaDB disabled and FTS5 not available');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -586,10 +644,26 @@ export class SessionSearch {
|
||||
return this.db.prepare(sql).all(...params) as UserPromptSearchResult[];
|
||||
}
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
return [];
|
||||
// LIKE fallback for user prompts text search (no FTS table for this entity)
|
||||
baseConditions.push('up.prompt_text LIKE ?');
|
||||
params.push(`%${query}%`);
|
||||
|
||||
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
|
||||
const orderClause = orderBy === 'date_asc'
|
||||
? 'ORDER BY up.created_at_epoch ASC'
|
||||
: 'ORDER BY up.created_at_epoch DESC';
|
||||
|
||||
const sql = `
|
||||
SELECT up.*
|
||||
FROM user_prompts up
|
||||
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
return this.db.prepare(sql).all(...params) as UserPromptSearchResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ export class SessionStore {
|
||||
this.db.run('PRAGMA journal_mode = WAL');
|
||||
this.db.run('PRAGMA synchronous = NORMAL');
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
this.db.run('PRAGMA journal_size_limit = 4194304'); // 4MB WAL cap (#1956)
|
||||
|
||||
// Initialize schema if needed (fresh database)
|
||||
this.initializeSchema();
|
||||
|
||||
@@ -557,6 +557,32 @@ export class WorkerService {
|
||||
logger.error('WORKER', 'Stale session reaper error with non-Error', {}, new Error(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
// Purge failed pending messages to prevent unbounded queue growth (#1957)
|
||||
try {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const purged = pendingStore.clearFailed();
|
||||
if (purged > 0) {
|
||||
logger.info('SYSTEM', `Purged ${purged} failed pending messages`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
logger.error('WORKER', 'Failed message purge error', {}, e);
|
||||
} else {
|
||||
logger.error('WORKER', 'Failed message purge error with non-Error', {}, new Error(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic WAL checkpoint to prevent unbounded WAL growth (#1956)
|
||||
try {
|
||||
this.dbManager.getSessionStore().db.run('PRAGMA wal_checkpoint(PASSIVE)');
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
logger.error('WORKER', 'WAL checkpoint error', {}, e);
|
||||
} else {
|
||||
logger.error('WORKER', 'WAL checkpoint error with non-Error', {}, new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
// Auto-recover orphaned queues (fire-and-forget with error logging)
|
||||
|
||||
@@ -97,6 +97,12 @@ export class SearchManager {
|
||||
delete normalized.filePath;
|
||||
}
|
||||
|
||||
// Map concept (singular, HTTP query param) to concepts (plural, internal key)
|
||||
if (normalized.concept && !normalized.concepts) {
|
||||
normalized.concepts = normalized.concept;
|
||||
delete normalized.concept;
|
||||
}
|
||||
|
||||
// Parse comma-separated concepts into array
|
||||
if (normalized.concepts && typeof normalized.concepts === 'string') {
|
||||
normalized.concepts = normalized.concepts.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
@@ -277,14 +283,18 @@ export class SearchManager {
|
||||
logger.debug('SEARCH', 'ChromaDB found no matches (final result, no FTS5 fallback)', {});
|
||||
}
|
||||
}
|
||||
// ChromaDB not initialized - mark as failed to show proper error message
|
||||
// ChromaDB not initialized - fall back to FTS5 keyword search (#1913, #2048)
|
||||
else if (query) {
|
||||
chromaFailed = true;
|
||||
logger.debug('SEARCH', 'ChromaDB not initialized - semantic search unavailable', {});
|
||||
logger.debug('SEARCH', 'Install UVX/Python to enable vector search', { url: 'https://docs.astral.sh/uv/getting-started/installation/' });
|
||||
observations = [];
|
||||
sessions = [];
|
||||
prompts = [];
|
||||
logger.debug('SEARCH', 'ChromaDB not initialized — falling back to FTS5 keyword search', {});
|
||||
if (searchObservations) {
|
||||
observations = this.sessionSearch.searchObservations(query, { ...options, type: obs_type, concepts, files });
|
||||
}
|
||||
if (searchSessions) {
|
||||
sessions = this.sessionSearch.searchSessions(query, options);
|
||||
}
|
||||
if (searchPrompts) {
|
||||
prompts = this.sessionSearch.searchUserPrompts(query, options);
|
||||
}
|
||||
}
|
||||
|
||||
const totalResults = observations.length + sessions.length + prompts.length;
|
||||
|
||||
@@ -80,17 +80,31 @@ export async function processAgentResponse(
|
||||
|
||||
const summary = parseSummary(text, session.sessionDbId, summaryExpected);
|
||||
|
||||
if (
|
||||
// Detect non-XML responses (auth errors, rate limits, garbled output).
|
||||
// When the response contains no parseable XML and produced no observations,
|
||||
// mark the pending messages as failed instead of confirming them — this prevents
|
||||
// silent data loss when the LLM returns garbage (#1874).
|
||||
const isNonXmlResponse = (
|
||||
text.trim() &&
|
||||
observations.length === 0 &&
|
||||
!summary &&
|
||||
!/<observation>|<summary>|<skip_summary\b/.test(text)
|
||||
) {
|
||||
);
|
||||
|
||||
if (isNonXmlResponse) {
|
||||
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
||||
logger.warn('PARSER', `${agentName} returned non-XML response; observation content was discarded`, {
|
||||
logger.warn('PARSER', `${agentName} returned non-XML response; marking messages as failed for retry (#1874)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
preview
|
||||
});
|
||||
|
||||
// Mark messages as failed (retry logic in PendingMessageStore handles retries)
|
||||
const pendingStore = sessionManager.getPendingMessageStore();
|
||||
for (const messageId of session.processingMessageIds) {
|
||||
pendingStore.markFailed(messageId);
|
||||
}
|
||||
session.processingMessageIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert nullable fields to empty strings for storeSummary (if summary exists)
|
||||
|
||||
@@ -38,7 +38,14 @@ export class ViewerRoutes extends BaseRouteHandler {
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth = this.wrapHandler((req: Request, res: Response): void => {
|
||||
res.json({ status: 'ok', timestamp: Date.now() });
|
||||
// Include queue liveness info so monitoring can detect dead queues (#1867)
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
activeSessions
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user