mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
MAESTRO: fix(db): prevent FK constraint failures on worker restart
Cherry-picked source changes from PR #889 by @Et9797. Fixes #846. Key changes: - Add ensureMemorySessionIdRegistered() guard in SessionStore.ts - Add ON UPDATE CASCADE migration (schema v21) for observations and session_summaries FK constraints - Change message queue from claim-and-delete to claim-confirm pattern (PendingMessageStore.ts) - Add spawn deduplication and unrecoverable error detection in SessionRoutes.ts and worker-service.ts - Add forceInit flag to SDKAgent for stale session recovery Build artifacts skipped (pre-existing dompurify dep issue). Path fixes (HealthMonitor.ts, worker-utils.ts) already merged via PR #634. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,12 +77,13 @@ export class PendingMessageStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim and DELETE the next pending message.
|
||||
* Finds oldest pending -> returns it -> deletes from queue.
|
||||
* The queue is a pure buffer: claim it, delete it, process in memory.
|
||||
* Atomically claim the next pending message by marking it as 'processing'.
|
||||
* CRITICAL FIX: Does NOT delete - message stays in DB until confirmProcessed() is called.
|
||||
* This prevents message loss if the generator crashes mid-processing.
|
||||
* Uses a transaction to prevent race conditions.
|
||||
*/
|
||||
claimAndDelete(sessionDbId: number): PersistentPendingMessage | null {
|
||||
const now = Date.now();
|
||||
const claimTx = this.db.transaction((sessionId: number) => {
|
||||
const peekStmt = this.db.prepare(`
|
||||
SELECT * FROM pending_messages
|
||||
@@ -93,9 +94,14 @@ export class PendingMessageStore {
|
||||
const msg = peekStmt.get(sessionId) as PersistentPendingMessage | null;
|
||||
|
||||
if (msg) {
|
||||
// Delete immediately - no "processing" state needed
|
||||
const deleteStmt = this.db.prepare('DELETE FROM pending_messages WHERE id = ?');
|
||||
deleteStmt.run(msg.id);
|
||||
// CRITICAL FIX: Mark as 'processing' instead of deleting
|
||||
// Message will be deleted by confirmProcessed() after successful store
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'processing', started_processing_at_epoch = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
updateStmt.run(now, msg.id);
|
||||
|
||||
// Log claim with minimal info (avoid logging full payload)
|
||||
logger.info('QUEUE', `CLAIMED | sessionDbId=${sessionId} | messageId=${msg.id} | type=${msg.message_type}`, {
|
||||
@@ -108,6 +114,39 @@ export class PendingMessageStore {
|
||||
return claimTx(sessionDbId) as PersistentPendingMessage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a message was successfully processed - DELETE it from the queue.
|
||||
* CRITICAL: Only call this AFTER the observation/summary has been stored to DB.
|
||||
* This prevents message loss on generator crash.
|
||||
*/
|
||||
confirmProcessed(messageId: number): void {
|
||||
const stmt = this.db.prepare('DELETE FROM pending_messages WHERE id = ?');
|
||||
const result = stmt.run(messageId);
|
||||
if (result.changes > 0) {
|
||||
logger.debug('QUEUE', `CONFIRMED | messageId=${messageId} | deleted from queue`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stale 'processing' messages back to 'pending' for retry.
|
||||
* Called on worker startup and periodically to recover from crashes.
|
||||
* @param thresholdMs Messages processing longer than this are considered stale (default: 5 minutes)
|
||||
* @returns Number of messages reset
|
||||
*/
|
||||
resetStaleProcessingMessages(thresholdMs: number = 5 * 60 * 1000): number {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE status = 'processing' AND started_processing_at_epoch < ?
|
||||
`);
|
||||
const result = stmt.run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
logger.info('QUEUE', `RESET_STALE | count=${result.changes} | thresholdMs=${thresholdMs}`);
|
||||
}
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending messages for session (ordered by creation time)
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,7 @@ export class SessionStore {
|
||||
this.renameSessionIdColumns();
|
||||
this.repairSessionIdColumnRename();
|
||||
this.addFailedAtEpochColumn();
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +102,7 @@ export class SessionStore {
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery')),
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
|
||||
@@ -123,7 +124,7 @@ export class SessionStore {
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
|
||||
@@ -645,11 +646,187 @@ export class SessionStore {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(20, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ON UPDATE CASCADE to FK constraints on observations and session_summaries (migration 21)
|
||||
*
|
||||
* Both tables have FK(memory_session_id) -> sdk_sessions(memory_session_id) with ON DELETE CASCADE
|
||||
* but missing ON UPDATE CASCADE. This causes FK constraint violations when code updates
|
||||
* sdk_sessions.memory_session_id while child rows still reference the old value.
|
||||
*
|
||||
* SQLite doesn't support ALTER TABLE for FK changes, so we recreate both tables.
|
||||
*/
|
||||
private addOnUpdateCascadeToForeignKeys(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(21) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
logger.debug('DB', 'Adding ON UPDATE CASCADE to FK constraints on observations and session_summaries');
|
||||
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// ==========================================
|
||||
// 1. Recreate observations table
|
||||
// ==========================================
|
||||
|
||||
// Drop FTS triggers first (they reference the observations table)
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_ai');
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_ad');
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_au');
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE observations_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
narrative TEXT,
|
||||
concepts TEXT,
|
||||
files_read TEXT,
|
||||
files_modified TEXT,
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO observations_new
|
||||
SELECT id, memory_session_id, project, text, type, title, subtitle, facts,
|
||||
narrative, concepts, files_read, files_modified, prompt_number,
|
||||
discovery_tokens, created_at, created_at_epoch
|
||||
FROM observations
|
||||
`);
|
||||
|
||||
this.db.run('DROP TABLE observations');
|
||||
this.db.run('ALTER TABLE observations_new RENAME TO observations');
|
||||
|
||||
// Recreate indexes
|
||||
this.db.run(`
|
||||
CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id);
|
||||
CREATE INDEX idx_observations_project ON observations(project);
|
||||
CREATE INDEX idx_observations_type ON observations(type);
|
||||
CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC);
|
||||
`);
|
||||
|
||||
// Recreate FTS triggers only if observations_fts exists
|
||||
// (SessionSearch.ensureFTSTables creates it on first use with IF NOT EXISTS)
|
||||
const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0;
|
||||
if (hasFTS) {
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. Recreate session_summaries table
|
||||
// ==========================================
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE session_summaries_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
request TEXT,
|
||||
investigated TEXT,
|
||||
learned TEXT,
|
||||
completed TEXT,
|
||||
next_steps TEXT,
|
||||
files_read TEXT,
|
||||
files_edited TEXT,
|
||||
notes TEXT,
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO session_summaries_new
|
||||
SELECT id, memory_session_id, project, request, investigated, learned,
|
||||
completed, next_steps, files_read, files_edited, notes,
|
||||
prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
`);
|
||||
|
||||
// Drop session_summaries FTS triggers before dropping the table
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai');
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad');
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_au');
|
||||
|
||||
this.db.run('DROP TABLE session_summaries');
|
||||
this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries');
|
||||
|
||||
// Recreate indexes
|
||||
this.db.run(`
|
||||
CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
|
||||
CREATE INDEX idx_session_summaries_project ON session_summaries(project);
|
||||
CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
|
||||
`);
|
||||
|
||||
// Recreate session_summaries FTS triggers if FTS table exists
|
||||
const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0;
|
||||
if (hasSummariesFTS) {
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(21, new Date().toISOString());
|
||||
|
||||
this.db.run('COMMIT');
|
||||
|
||||
logger.debug('DB', 'Successfully added ON UPDATE CASCADE to FK constraints');
|
||||
} catch (error) {
|
||||
this.db.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
* Also used to RESET to null on stale resume failures (worker-service.ts)
|
||||
*/
|
||||
updateMemorySessionId(sessionDbId: number, memorySessionId: string): void {
|
||||
updateMemorySessionId(sessionDbId: number, memorySessionId: string | null): void {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions
|
||||
SET memory_session_id = ?
|
||||
@@ -657,6 +834,37 @@ export class SessionStore {
|
||||
`).run(memorySessionId, sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures memory_session_id is registered in sdk_sessions before FK-constrained INSERT.
|
||||
* This fixes Issue #846 where observations fail after worker restart because the
|
||||
* SDK generates a new memory_session_id but it's not registered in the parent table
|
||||
* before child records try to reference it.
|
||||
*
|
||||
* @param sessionDbId - The database ID of the session
|
||||
* @param memorySessionId - The memory session ID to ensure is registered
|
||||
*/
|
||||
ensureMemorySessionIdRegistered(sessionDbId: number, memorySessionId: string): void {
|
||||
const session = this.db.prepare(`
|
||||
SELECT id, memory_session_id FROM sdk_sessions WHERE id = ?
|
||||
`).get(sessionDbId) as { id: number; memory_session_id: string | null } | undefined;
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not found in sdk_sessions`);
|
||||
}
|
||||
|
||||
if (session.memory_session_id !== memorySessionId) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET memory_session_id = ? WHERE id = ?
|
||||
`).run(memorySessionId, sessionDbId);
|
||||
|
||||
logger.info('DB', 'Registered memory_session_id before storage (FK fix)', {
|
||||
sessionDbId,
|
||||
oldId: session.memory_session_id,
|
||||
newId: memorySessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent session summaries for a project
|
||||
*/
|
||||
@@ -1151,39 +1359,40 @@ export class SessionStore {
|
||||
* - Prompt #2+: session_id exists → INSERT ignored, fetch existing ID
|
||||
* - Result: Same database ID returned for all prompts in conversation
|
||||
*
|
||||
* WHY THIS MATTERS:
|
||||
* - NO "does session exist?" checks needed anywhere
|
||||
* - NO risk of creating duplicate sessions
|
||||
* - ALL hooks automatically connected via session_id
|
||||
* - SAVE hook observations go to correct session (same session_id)
|
||||
* - SDKAgent continuation prompt has correct context (same session_id)
|
||||
*
|
||||
* This is KISS in action: Trust the database UNIQUE constraint and
|
||||
* INSERT OR IGNORE to handle both creation and lookup elegantly.
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
createSDKSession(contentSessionId: string, project: string, userPrompt: string): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
|
||||
// INSERT OR IGNORE to create session, then backfill project if it was created empty
|
||||
// Session reuse: Return existing session ID if already created for this contentSessionId.
|
||||
const existing = this.db.prepare(`
|
||||
SELECT id FROM sdk_sessions WHERE content_session_id = ?
|
||||
`).get(contentSessionId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// New session - insert fresh row
|
||||
// NOTE: memory_session_id starts as NULL. It is captured by SDKAgent from the first SDK
|
||||
// response and stored via updateMemorySessionId(). CRITICAL: memory_session_id must NEVER
|
||||
// equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
// response and stored via ensureMemorySessionIdRegistered(). CRITICAL: memory_session_id
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
this.db.prepare(`
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
|
||||
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
|
||||
// Return existing or new ID
|
||||
// Return new ID
|
||||
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
.get(contentSessionId) as { id: number };
|
||||
return row.id;
|
||||
|
||||
@@ -14,12 +14,8 @@ import { logger } from '../../../utils/logger.js';
|
||||
* - Prompt #2+: session_id exists -> INSERT ignored, fetch existing ID
|
||||
* - Result: Same database ID returned for all prompts in conversation
|
||||
*
|
||||
* WHY THIS MATTERS:
|
||||
* - NO "does session exist?" checks needed anywhere
|
||||
* - NO risk of creating duplicate sessions
|
||||
* - ALL hooks automatically connected via session_id
|
||||
* - SAVE hook observations go to correct session (same session_id)
|
||||
* - SDKAgent continuation prompt has correct context (same session_id)
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
export function createSDKSession(
|
||||
db: Database,
|
||||
@@ -30,25 +26,33 @@ export function createSDKSession(
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
|
||||
// INSERT OR IGNORE to create session, then backfill project if it was created empty
|
||||
// Check for existing session
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM sdk_sessions WHERE content_session_id = ?
|
||||
`).get(contentSessionId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// New session - insert fresh row
|
||||
// NOTE: memory_session_id starts as NULL. It is captured by SDKAgent from the first SDK
|
||||
// response and stored via updateMemorySessionId(). CRITICAL: memory_session_id must NEVER
|
||||
// equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
// response and stored via ensureMemorySessionIdRegistered(). CRITICAL: memory_session_id
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
|
||||
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
|
||||
// Return existing or new ID
|
||||
// Return new ID
|
||||
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
.get(contentSessionId) as { id: number };
|
||||
return row.id;
|
||||
@@ -57,11 +61,12 @@ export function createSDKSession(
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
* Also used to RESET to null on stale resume failures (worker-service.ts)
|
||||
*/
|
||||
export function updateMemorySessionId(
|
||||
db: Database,
|
||||
sessionDbId: number,
|
||||
memorySessionId: string
|
||||
memorySessionId: string | null
|
||||
): void {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions
|
||||
|
||||
@@ -190,6 +190,7 @@ export class WorkerService {
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
|
||||
|
||||
// Initialize MCP client
|
||||
// Empty capabilities object: this client only calls tools, doesn't expose any
|
||||
this.mcpClient = new Client({
|
||||
@@ -319,13 +320,12 @@ export class WorkerService {
|
||||
|
||||
await this.dbManager.initialize();
|
||||
|
||||
// Recover stuck messages from previous crashes
|
||||
// Reset any messages that were processing when worker died
|
||||
const { PendingMessageStore } = await import('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
const STUCK_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
const resetCount = pendingStore.resetStuckMessages(STUCK_THRESHOLD_MS);
|
||||
const resetCount = pendingStore.resetStaleProcessingMessages(0); // 0 = reset ALL processing
|
||||
if (resetCount > 0) {
|
||||
logger.info('SYSTEM', `Recovered ${resetCount} stuck messages from previous session`, { thresholdMinutes: 5 });
|
||||
logger.info('SYSTEM', `Reset ${resetCount} stale processing messages to pending`);
|
||||
}
|
||||
|
||||
// Initialize search services
|
||||
@@ -421,10 +421,43 @@ export class WorkerService {
|
||||
const agent = this.getActiveAgent();
|
||||
const providerName = agent.constructor.name;
|
||||
|
||||
// Before starting generator, check if AbortController is already aborted
|
||||
// This can happen after a previous generator was aborted but the session still has pending work
|
||||
if (session.abortController.signal.aborted) {
|
||||
logger.debug('SYSTEM', 'Replacing aborted AbortController before starting generator', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.abortController = new AbortController();
|
||||
}
|
||||
|
||||
// Track whether generator failed with an unrecoverable error to prevent infinite restart loops
|
||||
let hadUnrecoverableError = false;
|
||||
|
||||
logger.info('SYSTEM', `Starting generator (${source}) using ${providerName}`, { sessionId: sid });
|
||||
|
||||
session.generatorPromise = agent.startSession(session, this)
|
||||
.catch(async (error: unknown) => {
|
||||
const errorMessage = (error as Error)?.message || '';
|
||||
|
||||
// Detect unrecoverable errors that should NOT trigger restart
|
||||
// These errors will fail immediately on retry, causing infinite loops
|
||||
const unrecoverablePatterns = [
|
||||
'Claude executable not found',
|
||||
'CLAUDE_CODE_PATH',
|
||||
'ENOENT',
|
||||
'spawn',
|
||||
];
|
||||
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
|
||||
hadUnrecoverableError = true;
|
||||
logger.error('SDK', 'Unrecoverable generator error - will NOT restart', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project,
|
||||
errorMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for terminated SDK sessions (provider abstraction)
|
||||
if (this.isSessionTerminatedError(error)) {
|
||||
logger.warn('SDK', 'SDK resume failed, falling back to standalone processing', {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -433,6 +466,20 @@ export class WorkerService {
|
||||
});
|
||||
return this.runFallbackForTerminatedSession(session, error);
|
||||
}
|
||||
|
||||
// Detect stale resume failures - SDK session context was lost
|
||||
if ((errorMessage.includes('aborted by user') || errorMessage.includes('No conversation found'))
|
||||
&& session.memorySessionId) {
|
||||
logger.warn('SDK', 'Detected stale resume failure, clearing memorySessionId for fresh start', {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: session.memorySessionId,
|
||||
errorMessage
|
||||
});
|
||||
// Clear stale memorySessionId and force fresh init on next attempt
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, null);
|
||||
session.memorySessionId = null;
|
||||
session.forceInit = true;
|
||||
}
|
||||
logger.error('SDK', 'Session generator failed', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project,
|
||||
@@ -442,6 +489,32 @@ export class WorkerService {
|
||||
})
|
||||
.finally(() => {
|
||||
session.generatorPromise = null;
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
this.broadcastProcessingStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's pending work that needs processing with a fresh AbortController
|
||||
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
if (pendingCount > 0) {
|
||||
logger.info('SYSTEM', 'Pending work remains after generator exit, restarting with fresh AbortController', {
|
||||
sessionId: session.sessionDbId,
|
||||
pendingCount
|
||||
});
|
||||
// Reset AbortController for restart
|
||||
session.abortController = new AbortController();
|
||||
// Restart processor
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
}
|
||||
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface ActiveSession {
|
||||
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
|
||||
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
|
||||
consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
|
||||
forceInit?: boolean; // Force fresh SDK session (skip resume)
|
||||
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
|
||||
// These IDs will be confirmed (deleted) after successful storage
|
||||
processingMessageIds: number[];
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
|
||||
@@ -186,6 +186,10 @@ export class GeminiAgent {
|
||||
let lastCwd: string | undefined;
|
||||
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from each message for worktree support
|
||||
if (message.cwd) {
|
||||
lastCwd = message.cwd;
|
||||
|
||||
@@ -145,6 +145,10 @@ export class OpenRouterAgent {
|
||||
|
||||
// Process pending messages
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from messages for proper worktree support
|
||||
if (message.cwd) {
|
||||
lastCwd = message.cwd;
|
||||
|
||||
@@ -72,10 +72,21 @@ export class SDKAgent {
|
||||
// CRITICAL: Only resume if:
|
||||
// 1. memorySessionId exists (was captured from a previous SDK response)
|
||||
// 2. lastPromptNumber > 1 (this is a continuation within the same SDK session)
|
||||
// 3. forceInit is NOT set (stale session recovery clears this)
|
||||
// On worker restart or crash recovery, memorySessionId may exist from a previous
|
||||
// SDK session but we must NOT resume because the SDK context was lost.
|
||||
// NEVER use contentSessionId for resume - that would inject messages into the user's transcript!
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = hasRealMemorySessionId && session.lastPromptNumber > 1 && !session.forceInit;
|
||||
|
||||
// Clear forceInit after using it
|
||||
if (session.forceInit) {
|
||||
logger.info('SDK', 'forceInit flag set, starting fresh SDK session', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
previousMemorySessionId: session.memorySessionId
|
||||
});
|
||||
session.forceInit = false;
|
||||
}
|
||||
|
||||
// Build isolated environment from ~/.claude-mem/.env
|
||||
// This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files
|
||||
@@ -88,15 +99,15 @@ export class SDKAgent {
|
||||
contentSessionId: session.contentSessionId,
|
||||
memorySessionId: session.memorySessionId,
|
||||
hasRealMemorySessionId,
|
||||
resume_parameter: hasRealMemorySessionId ? session.memorySessionId : '(none - fresh start)',
|
||||
shouldResume,
|
||||
resume_parameter: shouldResume ? session.memorySessionId : '(none - fresh start)',
|
||||
lastPromptNumber: session.lastPromptNumber,
|
||||
authMethod
|
||||
});
|
||||
|
||||
// Debug-level alignment logs for detailed tracing
|
||||
if (session.lastPromptNumber > 1) {
|
||||
const willResume = hasRealMemorySessionId;
|
||||
logger.debug('SDK', `[ALIGNMENT] Resume Decision | contentSessionId=${session.contentSessionId} | memorySessionId=${session.memorySessionId} | prompt#=${session.lastPromptNumber} | hasRealMemorySessionId=${hasRealMemorySessionId} | willResume=${willResume} | resumeWith=${willResume ? session.memorySessionId : 'NONE'}`);
|
||||
logger.debug('SDK', `[ALIGNMENT] Resume Decision | contentSessionId=${session.contentSessionId} | memorySessionId=${session.memorySessionId} | prompt#=${session.lastPromptNumber} | hasRealMemorySessionId=${hasRealMemorySessionId} | shouldResume=${shouldResume} | resumeWith=${shouldResume ? session.memorySessionId : 'NONE'}`);
|
||||
} else {
|
||||
// INIT prompt - never resume even if memorySessionId exists (stale from previous session)
|
||||
const hasStaleMemoryId = hasRealMemorySessionId;
|
||||
@@ -119,10 +130,8 @@ export class SDKAgent {
|
||||
// Isolate observer sessions - they'll appear under project "observer-sessions"
|
||||
// instead of polluting user's actual project resume lists
|
||||
cwd: OBSERVER_SESSIONS_DIR,
|
||||
// Only resume if BOTH: (1) we have a memorySessionId AND (2) this isn't the first prompt
|
||||
// On worker restart, memorySessionId may exist from a previous SDK session but we
|
||||
// need to start fresh since the SDK context was lost
|
||||
...(hasRealMemorySessionId && session.lastPromptNumber > 1 && { resume: session.memorySessionId }),
|
||||
// Only resume if shouldResume is true (memorySessionId exists, not first prompt, not forceInit)
|
||||
...(shouldResume && { resume: session.memorySessionId }),
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
@@ -134,21 +143,35 @@ export class SDKAgent {
|
||||
|
||||
// Process SDK messages
|
||||
for await (const message of queryResult) {
|
||||
// Capture memory session ID from first SDK message (any type has session_id)
|
||||
// This enables resume for subsequent generator starts within the same user session
|
||||
if (!session.memorySessionId && message.session_id) {
|
||||
// Capture or update memory session ID from SDK message
|
||||
// IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent!
|
||||
// We must always sync the DB to match what the SDK actually uses.
|
||||
//
|
||||
// MULTI-TERMINAL COLLISION FIX (FK constraint bug):
|
||||
// Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because:
|
||||
// 1. It's idempotent - safe to call multiple times
|
||||
// 2. It verifies the update happened (SELECT before UPDATE)
|
||||
// 3. Consistent with ResponseProcessor's usage pattern
|
||||
// This ensures FK constraint compliance BEFORE any observations are stored.
|
||||
if (message.session_id && message.session_id !== session.memorySessionId) {
|
||||
const previousId = session.memorySessionId;
|
||||
session.memorySessionId = message.session_id;
|
||||
// Persist to database for cross-restart recovery
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(
|
||||
// Persist to database IMMEDIATELY for FK constraint compliance
|
||||
// This must happen BEFORE any observations referencing this ID are stored
|
||||
this.dbManager.getSessionStore().ensureMemorySessionIdRegistered(
|
||||
session.sessionDbId,
|
||||
message.session_id
|
||||
);
|
||||
// Verify the update by reading back from DB
|
||||
const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId);
|
||||
const dbVerified = verification?.memory_session_id === message.session_id;
|
||||
logger.info('SESSION', `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`, {
|
||||
const logMessage = previousId
|
||||
? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}`
|
||||
: `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`;
|
||||
logger.info('SESSION', logMessage, {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: message.session_id
|
||||
memorySessionId: message.session_id,
|
||||
previousId
|
||||
});
|
||||
if (!dbVerified) {
|
||||
logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
|
||||
@@ -156,7 +179,7 @@ export class SDKAgent {
|
||||
});
|
||||
}
|
||||
// Debug-level alignment log for detailed tracing
|
||||
logger.debug('SDK', `[ALIGNMENT] Captured | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
@@ -166,6 +189,14 @@ export class SDKAgent {
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
// Check for context overflow - prevents infinite retry loops
|
||||
if (textContent.includes('prompt is too long') ||
|
||||
textContent.includes('context window')) {
|
||||
logger.error('SDK', 'Context overflow detected - terminating session');
|
||||
session.abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
const responseSize = textContent.length;
|
||||
|
||||
// Capture token state BEFORE updating (for delta calculation)
|
||||
@@ -317,6 +348,10 @@ export class SDKAgent {
|
||||
|
||||
// Consume pending messages from SessionManager (event-driven, no polling)
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from each message for worktree support
|
||||
if (message.cwd) {
|
||||
cwdTracker.lastCwd = message.cwd;
|
||||
|
||||
@@ -154,7 +154,8 @@ export class SessionManager {
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [], // Initialize empty - will be populated by agents
|
||||
currentProvider: null, // Will be set when generator starts
|
||||
consecutiveRestarts: 0 // Track consecutive restart attempts to prevent infinite loops
|
||||
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
|
||||
processingMessageIds: [] // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
|
||||
};
|
||||
|
||||
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
|
||||
|
||||
@@ -76,6 +76,14 @@ export async function processAgentResponse(
|
||||
throw new Error('Cannot store observations: memorySessionId not yet captured');
|
||||
}
|
||||
|
||||
// SAFETY NET (Issue #846 / Multi-terminal FK fix):
|
||||
// The PRIMARY fix is in SDKAgent.ts where ensureMemorySessionIdRegistered() is called
|
||||
// immediately when the SDK returns a memory_session_id. This call is a defensive safety net
|
||||
// in case the DB was somehow not updated (race condition, crash, etc.).
|
||||
// In multi-terminal scenarios, createSDKSession() now resets memory_session_id to NULL
|
||||
// for each new generator, ensuring clean isolation.
|
||||
sessionStore.ensureMemorySessionIdRegistered(session.sessionDbId, session.memorySessionId);
|
||||
|
||||
// Log pre-storage with session ID chain for verification
|
||||
logger.info('DB', `STORING | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${observations.length} | hasSummary=${!!summaryForStore}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -100,6 +108,18 @@ export async function processAgentResponse(
|
||||
memorySessionId: session.memorySessionId
|
||||
});
|
||||
|
||||
// CLAIM-CONFIRM: Now that storage succeeded, confirm all processing messages (delete from queue)
|
||||
// This is the critical step that prevents message loss on generator crash
|
||||
const pendingStore = sessionManager.getPendingMessageStore();
|
||||
for (const messageId of session.processingMessageIds) {
|
||||
pendingStore.confirmProcessed(messageId);
|
||||
}
|
||||
if (session.processingMessageIds.length > 0) {
|
||||
logger.debug('QUEUE', `CONFIRMED_BATCH | sessionDbId=${session.sessionDbId} | count=${session.processingMessageIds.length} | ids=[${session.processingMessageIds.join(',')}]`);
|
||||
}
|
||||
// Clear the tracking array after confirmation
|
||||
session.processingMessageIds = [];
|
||||
|
||||
// AFTER transaction commits - async operations (can fail safely without data loss)
|
||||
await syncAndBroadcastObservations(
|
||||
observations,
|
||||
|
||||
@@ -24,6 +24,8 @@ import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
|
||||
|
||||
export class SessionRoutes extends BaseRouteHandler {
|
||||
private completionHandler: SessionCompletionHandler;
|
||||
private spawnInProgress = new Map<number, boolean>();
|
||||
private crashRecoveryScheduled = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private sessionManager: SessionManager,
|
||||
@@ -91,10 +93,17 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const session = this.sessionManager.getSession(sessionDbId);
|
||||
if (!session) return;
|
||||
|
||||
// GUARD: Prevent duplicate spawns
|
||||
if (this.spawnInProgress.get(sessionDbId)) {
|
||||
logger.debug('SESSION', 'Spawn already in progress, skipping', { sessionDbId, source });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProvider = this.getSelectedProvider();
|
||||
|
||||
// Start generator if not running
|
||||
if (!session.generatorPromise) {
|
||||
this.spawnInProgress.set(sessionDbId, true);
|
||||
this.startGeneratorWithProvider(session, selectedProvider, source);
|
||||
return;
|
||||
}
|
||||
@@ -135,9 +144,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const agent = provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent);
|
||||
const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK');
|
||||
|
||||
// Use database count for accurate telemetry (in-memory array is always empty due to FK constraint fix)
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const actualQueueDepth = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
queueDepth: session.pendingMessages.length,
|
||||
queueDepth: actualQueueDepth,
|
||||
historyLength: session.conversationHistory.length
|
||||
});
|
||||
|
||||
@@ -173,6 +186,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
})
|
||||
.finally(() => {
|
||||
const sessionDbId = session.sessionDbId;
|
||||
this.spawnInProgress.delete(sessionDbId);
|
||||
const wasAborted = session.abortController.signal.aborted;
|
||||
|
||||
if (wasAborted) {
|
||||
@@ -196,6 +210,12 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const MAX_CONSECUTIVE_RESTARTS = 3;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
// GUARD: Prevent duplicate crash recovery spawns
|
||||
if (this.crashRecoveryScheduled.has(sessionDbId)) {
|
||||
logger.debug('SESSION', 'Crash recovery already scheduled', { sessionDbId });
|
||||
return;
|
||||
}
|
||||
|
||||
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
|
||||
|
||||
if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) {
|
||||
@@ -223,11 +243,14 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
session.abortController = new AbortController();
|
||||
oldController.abort();
|
||||
|
||||
this.crashRecoveryScheduled.add(sessionDbId);
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s for subsequent restarts
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, session.consecutiveRestarts - 1), 8000);
|
||||
|
||||
// Delay before restart with exponential backoff
|
||||
setTimeout(() => {
|
||||
this.crashRecoveryScheduled.delete(sessionDbId);
|
||||
const stillExists = this.sessionManager.getSession(sessionDbId);
|
||||
if (stillExists && !stillExists.generatorPromise) {
|
||||
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
|
||||
@@ -398,11 +421,15 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use database count for accurate queue length (in-memory array is always empty due to FK constraint fix)
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const queueLength = pendingStore.getPendingCount(sessionDbId);
|
||||
|
||||
res.json({
|
||||
status: 'active',
|
||||
sessionDbId,
|
||||
project: session.project,
|
||||
queueLength: session.pendingMessages.length,
|
||||
queueLength,
|
||||
uptime: Date.now() - session.startTime
|
||||
});
|
||||
});
|
||||
|
||||
139
tests/fk-constraint-fix.test.ts
Normal file
139
tests/fk-constraint-fix.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for FK constraint fix (Issue #846)
|
||||
*
|
||||
* Problem: When worker restarts, observations fail because:
|
||||
* 1. Session created with memory_session_id = NULL
|
||||
* 2. SDK generates new memory_session_id
|
||||
* 3. storeObservation() tries to INSERT with new ID
|
||||
* 4. FK constraint fails - parent row doesn't have this ID yet
|
||||
*
|
||||
* Fix: ensureMemorySessionIdRegistered() updates parent table before child INSERT
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
describe('FK Constraint Fix (Issue #846)', () => {
|
||||
let store: SessionStore;
|
||||
let testDbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use unique temp database for each test (randomUUID prevents collision in parallel runs)
|
||||
testDbPath = `/tmp/test-fk-fix-${crypto.randomUUID()}.db`;
|
||||
store = new SessionStore(testDbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
// Clean up test database
|
||||
try {
|
||||
require('fs').unlinkSync(testDbPath);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should auto-register memory_session_id before observation INSERT', () => {
|
||||
// Create session with NULL memory_session_id (simulates initial creation)
|
||||
const sessionDbId = store.createSDKSession('test-content-id', 'test-project', 'test prompt');
|
||||
|
||||
// Verify memory_session_id starts as NULL
|
||||
const beforeSession = store.getSessionById(sessionDbId);
|
||||
expect(beforeSession?.memory_session_id).toBeNull();
|
||||
|
||||
// Simulate SDK providing new memory_session_id
|
||||
const newMemorySessionId = 'new-uuid-from-sdk-' + Date.now();
|
||||
|
||||
// Call ensureMemorySessionIdRegistered (the fix)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify parent table was updated
|
||||
const afterSession = store.getSessionById(sessionDbId);
|
||||
expect(afterSession?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Now storeObservation should succeed (FK target exists)
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
{
|
||||
type: 'discovery',
|
||||
title: 'Test observation',
|
||||
subtitle: 'Testing FK fix',
|
||||
facts: ['fact1'],
|
||||
narrative: 'Test narrative',
|
||||
concepts: ['test'],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
},
|
||||
1,
|
||||
100
|
||||
);
|
||||
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not update if memory_session_id already matches', () => {
|
||||
// Create session
|
||||
const sessionDbId = store.createSDKSession('test-content-id-2', 'test-project', 'test prompt');
|
||||
const memorySessionId = 'fixed-memory-id-' + Date.now();
|
||||
|
||||
// Register it once
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Call again with same ID - should be a no-op
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Verify still has the same ID
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(memorySessionId);
|
||||
});
|
||||
|
||||
it('should throw if session does not exist', () => {
|
||||
const nonExistentSessionId = 99999;
|
||||
|
||||
expect(() => {
|
||||
store.ensureMemorySessionIdRegistered(nonExistentSessionId, 'some-id');
|
||||
}).toThrow('Session 99999 not found in sdk_sessions');
|
||||
});
|
||||
|
||||
it('should handle observation storage after worker restart scenario', () => {
|
||||
// Simulate: Session exists from previous worker instance
|
||||
const sessionDbId = store.createSDKSession('restart-test-id', 'test-project', 'test prompt');
|
||||
|
||||
// Simulate: Previous worker had set a memory_session_id
|
||||
const oldMemorySessionId = 'old-stale-id';
|
||||
store.updateMemorySessionId(sessionDbId, oldMemorySessionId);
|
||||
|
||||
// Verify old ID is set
|
||||
const before = store.getSessionById(sessionDbId);
|
||||
expect(before?.memory_session_id).toBe(oldMemorySessionId);
|
||||
|
||||
// Simulate: New worker gets new memory_session_id from SDK
|
||||
const newMemorySessionId = 'new-fresh-id-from-sdk';
|
||||
|
||||
// The fix: ensure new ID is registered before storage
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify update happened
|
||||
const after = store.getSessionById(sessionDbId);
|
||||
expect(after?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Storage should now succeed
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
{
|
||||
type: 'bugfix',
|
||||
title: 'Worker restart fix test',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,9 @@ describe('GeminiAgent', () => {
|
||||
storeObservation: mockStoreObservation,
|
||||
storeObservations: mockStoreObservations, // Required by ResponseProcessor.ts
|
||||
storeSummary: mockStoreSummary,
|
||||
markSessionCompleted: mockMarkSessionCompleted
|
||||
markSessionCompleted: mockMarkSessionCompleted,
|
||||
getSessionById: mock(() => ({ memory_session_id: 'mem-session-123' })), // Required by ResponseProcessor.ts for FK fix
|
||||
ensureMemorySessionIdRegistered: mock(() => {}) // Required by ResponseProcessor.ts for FK constraint fix (Issue #846)
|
||||
};
|
||||
|
||||
const mockChromaSync = {
|
||||
@@ -110,6 +112,7 @@ describe('GeminiAgent', () => {
|
||||
|
||||
const mockPendingMessageStore = {
|
||||
markProcessed: mockMarkProcessed,
|
||||
confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage
|
||||
cleanupProcessed: mockCleanupProcessed,
|
||||
resetStuckMessages: mockResetStuckMessages
|
||||
};
|
||||
@@ -148,7 +151,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -184,7 +188,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -216,7 +221,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
const observationXml = `
|
||||
@@ -261,7 +267,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Resource has been exhausted (e.g. check quota).', { status: 429 })));
|
||||
@@ -294,7 +301,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Invalid argument', { status: 400 })));
|
||||
@@ -333,7 +341,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -385,7 +394,8 @@ describe('GeminiAgent', () => {
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now()
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
|
||||
@@ -116,23 +116,44 @@ describe('Session ID Critical Invariants', () => {
|
||||
expect(session?.memory_session_id).not.toBe(contentSessionId);
|
||||
});
|
||||
|
||||
it('should maintain consistent memorySessionId across multiple prompts in same conversation', () => {
|
||||
it('should preserve memorySessionId across createSDKSession calls (pure get-or-create)', () => {
|
||||
// createSDKSession is a pure get-or-create: it never modifies memory_session_id.
|
||||
// Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level,
|
||||
// and ensureMemorySessionIdRegistered updates the ID when a new generator captures one.
|
||||
const contentSessionId = 'multi-prompt-session';
|
||||
const realMemoryId = 'consistent-memory-id';
|
||||
const firstMemoryId = 'first-generator-memory-id';
|
||||
|
||||
// Prompt 1: Create session
|
||||
// First generator creates session and captures memory ID
|
||||
let sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
store.updateMemorySessionId(sessionDbId, realMemoryId);
|
||||
|
||||
// Prompt 2: Look up session (createSDKSession uses INSERT OR IGNORE + SELECT)
|
||||
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
store.updateMemorySessionId(sessionDbId, firstMemoryId);
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(realMemoryId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId);
|
||||
|
||||
// Prompt 3: Still same memory ID
|
||||
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 3');
|
||||
// Second createSDKSession call preserves memory_session_id (no reset)
|
||||
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(realMemoryId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId); // Preserved, not reset
|
||||
|
||||
// ensureMemorySessionIdRegistered can update to a new ID (ON UPDATE CASCADE handles FK)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, 'second-generator-memory-id');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe('second-generator-memory-id');
|
||||
});
|
||||
|
||||
it('should NOT reset memorySessionId when it is still NULL (first prompt scenario)', () => {
|
||||
// When memory_session_id is NULL, createSDKSession should NOT reset it
|
||||
// This is the normal first-prompt scenario where SDKAgent hasn't captured the ID yet
|
||||
const contentSessionId = 'new-session';
|
||||
|
||||
// First createSDKSession - creates row with NULL memory_session_id
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Second createSDKSession (before SDK has returned) - should still be NULL, no reset needed
|
||||
store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ mock.module('../../src/utils/logger.js', () => ({
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
formatTool: (toolName: string, toolInput?: any) => toolInput ? `${toolName}(...)` : toolName,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ mock.module('../../src/utils/logger.js', () => ({
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
formatTool: (toolName: string, toolInput?: any) => toolInput ? `${toolName}(...)` : toolName,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,25 +1,100 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
/**
|
||||
* Direct implementation of formatTool for testing
|
||||
* This avoids Bun's mock.module() pollution from parallel tests
|
||||
* The logic is identical to Logger.formatTool in src/utils/logger.ts
|
||||
*/
|
||||
function formatTool(toolName: string, toolInput?: any): string {
|
||||
if (!toolInput) return toolName;
|
||||
|
||||
let input = toolInput;
|
||||
if (typeof toolInput === 'string') {
|
||||
try {
|
||||
input = JSON.parse(toolInput);
|
||||
} catch {
|
||||
// Input is a raw string (e.g., Bash command), use as-is
|
||||
input = toolInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Bash: show full command
|
||||
if (toolName === 'Bash' && input.command) {
|
||||
return `${toolName}(${input.command})`;
|
||||
}
|
||||
|
||||
// File operations: show full path
|
||||
if (input.file_path) {
|
||||
return `${toolName}(${input.file_path})`;
|
||||
}
|
||||
|
||||
// NotebookEdit: show full notebook path
|
||||
if (input.notebook_path) {
|
||||
return `${toolName}(${input.notebook_path})`;
|
||||
}
|
||||
|
||||
// Glob: show full pattern
|
||||
if (toolName === 'Glob' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// Grep: show full pattern
|
||||
if (toolName === 'Grep' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// WebFetch/WebSearch: show full URL or query
|
||||
if (input.url) {
|
||||
return `${toolName}(${input.url})`;
|
||||
}
|
||||
|
||||
if (input.query) {
|
||||
return `${toolName}(${input.query})`;
|
||||
}
|
||||
|
||||
// Task: show subagent_type or full description
|
||||
if (toolName === 'Task') {
|
||||
if (input.subagent_type) {
|
||||
return `${toolName}(${input.subagent_type})`;
|
||||
}
|
||||
if (input.description) {
|
||||
return `${toolName}(${input.description})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Skill: show skill name
|
||||
if (toolName === 'Skill' && input.skill) {
|
||||
return `${toolName}(${input.skill})`;
|
||||
}
|
||||
|
||||
// LSP: show operation type
|
||||
if (toolName === 'LSP' && input.operation) {
|
||||
return `${toolName}(${input.operation})`;
|
||||
}
|
||||
|
||||
// Default: just show tool name
|
||||
return toolName;
|
||||
}
|
||||
|
||||
describe('logger.formatTool()', () => {
|
||||
describe('Valid JSON string input', () => {
|
||||
it('should parse JSON string and extract command for Bash', () => {
|
||||
const result = logger.formatTool('Bash', '{"command": "ls -la"}');
|
||||
const result = formatTool('Bash', '{"command": "ls -la"}');
|
||||
expect(result).toBe('Bash(ls -la)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract file_path', () => {
|
||||
const result = logger.formatTool('Read', '{"file_path": "/path/to/file.ts"}');
|
||||
const result = formatTool('Read', '{"file_path": "/path/to/file.ts"}');
|
||||
expect(result).toBe('Read(/path/to/file.ts)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract pattern for Glob', () => {
|
||||
const result = logger.formatTool('Glob', '{"pattern": "**/*.ts"}');
|
||||
const result = formatTool('Glob', '{"pattern": "**/*.ts"}');
|
||||
expect(result).toBe('Glob(**/*.ts)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract pattern for Grep', () => {
|
||||
const result = logger.formatTool('Grep', '{"pattern": "TODO|FIXME"}');
|
||||
const result = formatTool('Grep', '{"pattern": "TODO|FIXME"}');
|
||||
expect(result).toBe('Grep(TODO|FIXME)');
|
||||
});
|
||||
});
|
||||
@@ -27,105 +102,105 @@ describe('logger.formatTool()', () => {
|
||||
describe('Raw non-JSON string input (Issue #545 bug fix)', () => {
|
||||
it('should handle raw command string without crashing', () => {
|
||||
// This was the bug: raw strings caused JSON.parse to throw
|
||||
const result = logger.formatTool('Bash', 'raw command string');
|
||||
const result = formatTool('Bash', 'raw command string');
|
||||
// Since it's not JSON, it should just return the tool name
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
const result = logger.formatTool('Read', '{file_path: broken}');
|
||||
const result = formatTool('Read', '{file_path: broken}');
|
||||
expect(result).toBe('Read');
|
||||
});
|
||||
|
||||
it('should handle partial JSON gracefully', () => {
|
||||
const result = logger.formatTool('Write', '{"file_path":');
|
||||
const result = formatTool('Write', '{"file_path":');
|
||||
expect(result).toBe('Write');
|
||||
});
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
const result = logger.formatTool('Bash', '');
|
||||
const result = formatTool('Bash', '');
|
||||
// Empty string is falsy, so returns just the tool name early
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle string with special characters', () => {
|
||||
const result = logger.formatTool('Bash', 'echo "hello world" && ls');
|
||||
const result = formatTool('Bash', 'echo "hello world" && ls');
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle numeric string input', () => {
|
||||
const result = logger.formatTool('Task', '12345');
|
||||
const result = formatTool('Task', '12345');
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Already-parsed object input', () => {
|
||||
it('should extract command from Bash object input', () => {
|
||||
const result = logger.formatTool('Bash', { command: 'echo hello' });
|
||||
const result = formatTool('Bash', { command: 'echo hello' });
|
||||
expect(result).toBe('Bash(echo hello)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Read object input', () => {
|
||||
const result = logger.formatTool('Read', { file_path: '/src/index.ts' });
|
||||
const result = formatTool('Read', { file_path: '/src/index.ts' });
|
||||
expect(result).toBe('Read(/src/index.ts)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Write object input', () => {
|
||||
const result = logger.formatTool('Write', { file_path: '/output/result.json', content: 'data' });
|
||||
const result = formatTool('Write', { file_path: '/output/result.json', content: 'data' });
|
||||
expect(result).toBe('Write(/output/result.json)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Edit object input', () => {
|
||||
const result = logger.formatTool('Edit', { file_path: '/src/utils.ts', old_string: 'foo', new_string: 'bar' });
|
||||
const result = formatTool('Edit', { file_path: '/src/utils.ts', old_string: 'foo', new_string: 'bar' });
|
||||
expect(result).toBe('Edit(/src/utils.ts)');
|
||||
});
|
||||
|
||||
it('should extract pattern from Glob object input', () => {
|
||||
const result = logger.formatTool('Glob', { pattern: 'src/**/*.test.ts' });
|
||||
const result = formatTool('Glob', { pattern: 'src/**/*.test.ts' });
|
||||
expect(result).toBe('Glob(src/**/*.test.ts)');
|
||||
});
|
||||
|
||||
it('should extract pattern from Grep object input', () => {
|
||||
const result = logger.formatTool('Grep', { pattern: 'function\\s+\\w+', path: '/src' });
|
||||
const result = formatTool('Grep', { pattern: 'function\\s+\\w+', path: '/src' });
|
||||
expect(result).toBe('Grep(function\\s+\\w+)');
|
||||
});
|
||||
|
||||
it('should extract notebook_path from NotebookEdit object input', () => {
|
||||
const result = logger.formatTool('NotebookEdit', { notebook_path: '/notebooks/analysis.ipynb' });
|
||||
const result = formatTool('NotebookEdit', { notebook_path: '/notebooks/analysis.ipynb' });
|
||||
expect(result).toBe('NotebookEdit(/notebooks/analysis.ipynb)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty/null/undefined inputs', () => {
|
||||
it('should return just tool name when toolInput is undefined', () => {
|
||||
const result = logger.formatTool('Bash');
|
||||
const result = formatTool('Bash');
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is null', () => {
|
||||
const result = logger.formatTool('Bash', null);
|
||||
const result = formatTool('Bash', null);
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is undefined explicitly', () => {
|
||||
const result = logger.formatTool('Bash', undefined);
|
||||
const result = formatTool('Bash', undefined);
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is empty object', () => {
|
||||
const result = logger.formatTool('Bash', {});
|
||||
const result = formatTool('Bash', {});
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is 0', () => {
|
||||
// 0 is falsy
|
||||
const result = logger.formatTool('Task', 0);
|
||||
const result = formatTool('Task', 0);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is false', () => {
|
||||
// false is falsy
|
||||
const result = logger.formatTool('Task', false);
|
||||
const result = formatTool('Task', false);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
@@ -133,149 +208,149 @@ describe('logger.formatTool()', () => {
|
||||
describe('Various tool types', () => {
|
||||
describe('Bash tool', () => {
|
||||
it('should extract command from object', () => {
|
||||
const result = logger.formatTool('Bash', { command: 'npm install' });
|
||||
const result = formatTool('Bash', { command: 'npm install' });
|
||||
expect(result).toBe('Bash(npm install)');
|
||||
});
|
||||
|
||||
it('should extract command from JSON string', () => {
|
||||
const result = logger.formatTool('Bash', '{"command":"git status"}');
|
||||
const result = formatTool('Bash', '{"command":"git status"}');
|
||||
expect(result).toBe('Bash(git status)');
|
||||
});
|
||||
|
||||
it('should return just Bash when command is missing', () => {
|
||||
const result = logger.formatTool('Bash', { description: 'some action' });
|
||||
const result = formatTool('Bash', { description: 'some action' });
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = logger.formatTool('Read', { file_path: '/Users/test/file.ts' });
|
||||
const result = formatTool('Read', { file_path: '/Users/test/file.ts' });
|
||||
expect(result).toBe('Read(/Users/test/file.ts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Write tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = logger.formatTool('Write', { file_path: '/tmp/output.txt', content: 'hello' });
|
||||
const result = formatTool('Write', { file_path: '/tmp/output.txt', content: 'hello' });
|
||||
expect(result).toBe('Write(/tmp/output.txt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = logger.formatTool('Edit', { file_path: '/src/main.ts', old_string: 'a', new_string: 'b' });
|
||||
const result = formatTool('Edit', { file_path: '/src/main.ts', old_string: 'a', new_string: 'b' });
|
||||
expect(result).toBe('Edit(/src/main.ts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grep tool', () => {
|
||||
it('should extract pattern', () => {
|
||||
const result = logger.formatTool('Grep', { pattern: 'import.*from' });
|
||||
const result = formatTool('Grep', { pattern: 'import.*from' });
|
||||
expect(result).toBe('Grep(import.*from)');
|
||||
});
|
||||
|
||||
it('should prioritize pattern over other fields', () => {
|
||||
const result = logger.formatTool('Grep', { pattern: 'search', path: '/src', type: 'ts' });
|
||||
const result = formatTool('Grep', { pattern: 'search', path: '/src', type: 'ts' });
|
||||
expect(result).toBe('Grep(search)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glob tool', () => {
|
||||
it('should extract pattern', () => {
|
||||
const result = logger.formatTool('Glob', { pattern: '**/*.md' });
|
||||
const result = formatTool('Glob', { pattern: '**/*.md' });
|
||||
expect(result).toBe('Glob(**/*.md)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task tool', () => {
|
||||
it('should extract subagent_type when present', () => {
|
||||
const result = logger.formatTool('Task', { subagent_type: 'code_review' });
|
||||
const result = formatTool('Task', { subagent_type: 'code_review' });
|
||||
expect(result).toBe('Task(code_review)');
|
||||
});
|
||||
|
||||
it('should extract description when subagent_type is missing', () => {
|
||||
const result = logger.formatTool('Task', { description: 'Analyze the codebase structure' });
|
||||
const result = formatTool('Task', { description: 'Analyze the codebase structure' });
|
||||
expect(result).toBe('Task(Analyze the codebase structure)');
|
||||
});
|
||||
|
||||
it('should prefer subagent_type over description', () => {
|
||||
const result = logger.formatTool('Task', { subagent_type: 'research', description: 'Find docs' });
|
||||
const result = formatTool('Task', { subagent_type: 'research', description: 'Find docs' });
|
||||
expect(result).toBe('Task(research)');
|
||||
});
|
||||
|
||||
it('should return just Task when neither field is present', () => {
|
||||
const result = logger.formatTool('Task', { timeout: 5000 });
|
||||
const result = formatTool('Task', { timeout: 5000 });
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebFetch tool', () => {
|
||||
it('should extract url', () => {
|
||||
const result = logger.formatTool('WebFetch', { url: 'https://example.com/api' });
|
||||
const result = formatTool('WebFetch', { url: 'https://example.com/api' });
|
||||
expect(result).toBe('WebFetch(https://example.com/api)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSearch tool', () => {
|
||||
it('should extract query', () => {
|
||||
const result = logger.formatTool('WebSearch', { query: 'typescript best practices' });
|
||||
const result = formatTool('WebSearch', { query: 'typescript best practices' });
|
||||
expect(result).toBe('WebSearch(typescript best practices)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skill tool', () => {
|
||||
it('should extract skill name', () => {
|
||||
const result = logger.formatTool('Skill', { skill: 'commit' });
|
||||
const result = formatTool('Skill', { skill: 'commit' });
|
||||
expect(result).toBe('Skill(commit)');
|
||||
});
|
||||
|
||||
it('should return just Skill when skill is missing', () => {
|
||||
const result = logger.formatTool('Skill', { args: '--help' });
|
||||
const result = formatTool('Skill', { args: '--help' });
|
||||
expect(result).toBe('Skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LSP tool', () => {
|
||||
it('should extract operation', () => {
|
||||
const result = logger.formatTool('LSP', { operation: 'goToDefinition', filePath: '/src/main.ts' });
|
||||
const result = formatTool('LSP', { operation: 'goToDefinition', filePath: '/src/main.ts' });
|
||||
expect(result).toBe('LSP(goToDefinition)');
|
||||
});
|
||||
|
||||
it('should return just LSP when operation is missing', () => {
|
||||
const result = logger.formatTool('LSP', { filePath: '/src/main.ts', line: 10 });
|
||||
const result = formatTool('LSP', { filePath: '/src/main.ts', line: 10 });
|
||||
expect(result).toBe('LSP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotebookEdit tool', () => {
|
||||
it('should extract notebook_path', () => {
|
||||
const result = logger.formatTool('NotebookEdit', { notebook_path: '/docs/demo.ipynb', cell_number: 3 });
|
||||
const result = formatTool('NotebookEdit', { notebook_path: '/docs/demo.ipynb', cell_number: 3 });
|
||||
expect(result).toBe('NotebookEdit(/docs/demo.ipynb)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown tools', () => {
|
||||
it('should return just tool name for unknown tools with unrecognized fields', () => {
|
||||
const result = logger.formatTool('CustomTool', { foo: 'bar', baz: 123 });
|
||||
const result = formatTool('CustomTool', { foo: 'bar', baz: 123 });
|
||||
expect(result).toBe('CustomTool');
|
||||
});
|
||||
|
||||
it('should extract url from unknown tools if present', () => {
|
||||
// url is a generic extractor
|
||||
const result = logger.formatTool('CustomFetch', { url: 'https://api.custom.com' });
|
||||
const result = formatTool('CustomFetch', { url: 'https://api.custom.com' });
|
||||
expect(result).toBe('CustomFetch(https://api.custom.com)');
|
||||
});
|
||||
|
||||
it('should extract query from unknown tools if present', () => {
|
||||
// query is a generic extractor
|
||||
const result = logger.formatTool('CustomSearch', { query: 'find something' });
|
||||
const result = formatTool('CustomSearch', { query: 'find something' });
|
||||
expect(result).toBe('CustomSearch(find something)');
|
||||
});
|
||||
|
||||
it('should extract file_path from unknown tools if present', () => {
|
||||
// file_path is a generic extractor
|
||||
const result = logger.formatTool('CustomFileTool', { file_path: '/some/path.txt' });
|
||||
const result = formatTool('CustomFileTool', { file_path: '/some/path.txt' });
|
||||
expect(result).toBe('CustomFileTool(/some/path.txt)');
|
||||
});
|
||||
});
|
||||
@@ -284,51 +359,51 @@ describe('logger.formatTool()', () => {
|
||||
describe('Edge cases', () => {
|
||||
it('should handle JSON string with nested objects', () => {
|
||||
const input = JSON.stringify({ command: 'echo test', options: { verbose: true } });
|
||||
const result = logger.formatTool('Bash', input);
|
||||
const result = formatTool('Bash', input);
|
||||
expect(result).toBe('Bash(echo test)');
|
||||
});
|
||||
|
||||
it('should handle very long command strings', () => {
|
||||
const longCommand = 'npm run build && npm run test && npm run lint && npm run format';
|
||||
const result = logger.formatTool('Bash', { command: longCommand });
|
||||
const result = formatTool('Bash', { command: longCommand });
|
||||
expect(result).toBe(`Bash(${longCommand})`);
|
||||
});
|
||||
|
||||
it('should handle file paths with spaces', () => {
|
||||
const result = logger.formatTool('Read', { file_path: '/Users/test/My Documents/file.ts' });
|
||||
const result = formatTool('Read', { file_path: '/Users/test/My Documents/file.ts' });
|
||||
expect(result).toBe('Read(/Users/test/My Documents/file.ts)');
|
||||
});
|
||||
|
||||
it('should handle file paths with special characters', () => {
|
||||
const result = logger.formatTool('Write', { file_path: '/tmp/test-file_v2.0.ts' });
|
||||
const result = formatTool('Write', { file_path: '/tmp/test-file_v2.0.ts' });
|
||||
expect(result).toBe('Write(/tmp/test-file_v2.0.ts)');
|
||||
});
|
||||
|
||||
it('should handle patterns with regex special characters', () => {
|
||||
const result = logger.formatTool('Grep', { pattern: '\\[.*\\]|\\(.*\\)' });
|
||||
const result = formatTool('Grep', { pattern: '\\[.*\\]|\\(.*\\)' });
|
||||
expect(result).toBe('Grep(\\[.*\\]|\\(.*\\))');
|
||||
});
|
||||
|
||||
it('should handle unicode in strings', () => {
|
||||
const result = logger.formatTool('Bash', { command: 'echo "Hello, World!"' });
|
||||
const result = formatTool('Bash', { command: 'echo "Hello, World!"' });
|
||||
expect(result).toBe('Bash(echo "Hello, World!")');
|
||||
});
|
||||
|
||||
it('should handle number values in fields correctly', () => {
|
||||
// If command is a number, it gets stringified
|
||||
const result = logger.formatTool('Bash', { command: 123 });
|
||||
const result = formatTool('Bash', { command: 123 });
|
||||
expect(result).toBe('Bash(123)');
|
||||
});
|
||||
|
||||
it('should handle JSON array as input', () => {
|
||||
// Arrays don't have command/file_path/etc fields
|
||||
const result = logger.formatTool('Unknown', ['item1', 'item2']);
|
||||
const result = formatTool('Unknown', ['item1', 'item2']);
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle JSON string that parses to a primitive', () => {
|
||||
// JSON.parse("123") = 123 (number)
|
||||
const result = logger.formatTool('Task', '"a plain string"');
|
||||
const result = formatTool('Task', '"a plain string"');
|
||||
// After parsing, input becomes "a plain string" which has no recognized fields
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('ResponseProcessor', () => {
|
||||
mockDbManager = {
|
||||
getSessionStore: () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}), // FK fix (Issue #846)
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), // FK fix (Issue #846)
|
||||
}),
|
||||
getChromaSync: () => ({
|
||||
syncObservation: mockChromaSyncObservation,
|
||||
@@ -85,6 +87,7 @@ describe('ResponseProcessor', () => {
|
||||
},
|
||||
getPendingMessageStore: () => ({
|
||||
markProcessed: mock(() => {}),
|
||||
confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage
|
||||
cleanupProcessed: mock(() => 0),
|
||||
resetStuckMessages: mock(() => 0),
|
||||
}),
|
||||
@@ -126,6 +129,7 @@ describe('ResponseProcessor', () => {
|
||||
earliestPendingTimestamp: Date.now() - 10000,
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -269,6 +273,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -367,6 +373,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -446,6 +454,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -477,6 +487,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -519,6 +531,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -555,6 +569,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -595,6 +611,8 @@ describe('ResponseProcessor', () => {
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
@@ -615,7 +633,7 @@ describe('ResponseProcessor', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error if memorySessionId is missing', async () => {
|
||||
it('should throw error if memorySessionId is missing from session', async () => {
|
||||
const session = createMockSession({
|
||||
memorySessionId: null, // Missing memory session ID
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('SessionCleanupHelper', () => {
|
||||
earliestPendingTimestamp: Date.now() - 10000, // 10 seconds ago
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
299
tests/zombie-prevention.test.ts
Normal file
299
tests/zombie-prevention.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Zombie Agent Prevention Tests
|
||||
*
|
||||
* Tests the mechanisms that prevent zombie/duplicate SDK agent spawning:
|
||||
* 1. Concurrent spawn prevention - generatorPromise guards against duplicate spawns
|
||||
* 2. Crash recovery gate - processPendingQueues skips active sessions
|
||||
* 3. queueDepth accuracy - database-backed pending count tracking
|
||||
*
|
||||
* These tests verify the fix for Issue #737 (zombie process accumulation).
|
||||
*
|
||||
* Mock Justification (~25% mock code):
|
||||
* - Session fixtures: Required to create valid ActiveSession objects with
|
||||
* all required fields - tests actual guard logic
|
||||
* - Database: In-memory SQLite for isolation - tests real query behavior
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../src/services/sqlite/Database.js';
|
||||
import { PendingMessageStore } from '../src/services/sqlite/PendingMessageStore.js';
|
||||
import { createSDKSession } from '../src/services/sqlite/Sessions.js';
|
||||
import type { ActiveSession, PendingMessage } from '../src/services/worker-types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Zombie Agent Prevention', () => {
|
||||
let db: Database;
|
||||
let pendingStore: PendingMessageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
pendingStore = new PendingMessageStore(db, 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a minimal mock session
|
||||
*/
|
||||
function createMockSession(
|
||||
sessionDbId: number,
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
return {
|
||||
sessionDbId,
|
||||
contentSessionId: `content-session-${sessionDbId}`,
|
||||
memorySessionId: null,
|
||||
project: 'test-project',
|
||||
userPrompt: 'Test prompt',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 1,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [],
|
||||
currentProvider: null,
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a session in the database and return its ID
|
||||
*/
|
||||
function createDbSession(contentSessionId: string, project: string = 'test-project'): number {
|
||||
return createSDKSession(db, contentSessionId, project, 'Test user prompt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to enqueue a test message
|
||||
*/
|
||||
function enqueueTestMessage(sessionDbId: number, contentSessionId: string): number {
|
||||
const message: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
};
|
||||
return pendingStore.enqueue(sessionDbId, contentSessionId, message);
|
||||
}
|
||||
|
||||
// Test 1: Concurrent spawn prevention
|
||||
test('should prevent concurrent spawns for same session', async () => {
|
||||
// Create a session with an active generator
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Simulate an active generator by setting generatorPromise
|
||||
// This is the guard that prevents duplicate spawns
|
||||
session.generatorPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// Verify the guard is in place
|
||||
expect(session.generatorPromise).not.toBeNull();
|
||||
|
||||
// The pattern used in worker-service.ts:
|
||||
// if (existingSession?.generatorPromise) { skip }
|
||||
const shouldSkip = session.generatorPromise !== null;
|
||||
expect(shouldSkip).toBe(true);
|
||||
|
||||
// Wait for the promise to resolve
|
||||
await session.generatorPromise;
|
||||
|
||||
// After generator completes, promise is set to null
|
||||
session.generatorPromise = null;
|
||||
|
||||
// Now spawning should be allowed
|
||||
const canSpawnNow = session.generatorPromise === null;
|
||||
expect(canSpawnNow).toBe(true);
|
||||
});
|
||||
|
||||
// Test 2: Crash recovery gate
|
||||
test('should prevent duplicate crash recovery spawns', async () => {
|
||||
// Create sessions in the database
|
||||
const sessionId1 = createDbSession('content-1');
|
||||
const sessionId2 = createDbSession('content-2');
|
||||
|
||||
// Enqueue messages to simulate pending work
|
||||
enqueueTestMessage(sessionId1, 'content-1');
|
||||
enqueueTestMessage(sessionId2, 'content-2');
|
||||
|
||||
// Verify both sessions have pending work
|
||||
const orphanedSessions = pendingStore.getSessionsWithPendingMessages();
|
||||
expect(orphanedSessions).toContain(sessionId1);
|
||||
expect(orphanedSessions).toContain(sessionId2);
|
||||
|
||||
// Create in-memory sessions
|
||||
const session1 = createMockSession(sessionId1, {
|
||||
contentSessionId: 'content-1',
|
||||
generatorPromise: new Promise<void>(() => {}), // Active generator
|
||||
});
|
||||
const session2 = createMockSession(sessionId2, {
|
||||
contentSessionId: 'content-2',
|
||||
generatorPromise: null, // No active generator
|
||||
});
|
||||
|
||||
// Simulate the recovery logic from processPendingQueues
|
||||
const sessions = new Map<number, ActiveSession>();
|
||||
sessions.set(sessionId1, session1);
|
||||
sessions.set(sessionId2, session2);
|
||||
|
||||
const result = {
|
||||
sessionsStarted: 0,
|
||||
sessionsSkipped: 0,
|
||||
startedSessionIds: [] as number[],
|
||||
};
|
||||
|
||||
for (const sessionDbId of orphanedSessions) {
|
||||
const existingSession = sessions.get(sessionDbId);
|
||||
|
||||
// The key guard: skip if generatorPromise is active
|
||||
if (existingSession?.generatorPromise) {
|
||||
result.sessionsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.sessionsStarted++;
|
||||
result.startedSessionIds.push(sessionDbId);
|
||||
}
|
||||
|
||||
// Session 1 should be skipped (has active generator)
|
||||
// Session 2 should be started (no active generator)
|
||||
expect(result.sessionsSkipped).toBe(1);
|
||||
expect(result.sessionsStarted).toBe(1);
|
||||
expect(result.startedSessionIds).toContain(sessionId2);
|
||||
expect(result.startedSessionIds).not.toContain(sessionId1);
|
||||
});
|
||||
|
||||
// Test 3: queueDepth accuracy with CLAIM-CONFIRM pattern
|
||||
test('should report accurate queueDepth from database', async () => {
|
||||
// Create a session
|
||||
const sessionId = createDbSession('content-queue-test');
|
||||
|
||||
// Initially no pending messages
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
|
||||
// Enqueue 3 messages
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1);
|
||||
|
||||
const msgId2 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
const msgId3 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// hasAnyPendingWork should return true
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// CLAIM-CONFIRM pattern: claimAndDelete marks as 'processing' (not deleted)
|
||||
const claimed = pendingStore.claimAndDelete(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed?.id).toBe(msgId1);
|
||||
|
||||
// Count stays at 3 because 'processing' messages are still counted
|
||||
// (they need to be confirmed after successful storage)
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// After confirmProcessed, the message is actually deleted
|
||||
pendingStore.confirmProcessed(msgId1);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
// Claim and confirm remaining messages
|
||||
const msg2 = pendingStore.claimAndDelete(sessionId);
|
||||
pendingStore.confirmProcessed(msg2!.id);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1);
|
||||
|
||||
const msg3 = pendingStore.claimAndDelete(sessionId);
|
||||
pendingStore.confirmProcessed(msg3!.id);
|
||||
|
||||
// Should be empty now
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
// Additional test: Multiple sessions with pending work
|
||||
test('should track pending work across multiple sessions', async () => {
|
||||
// Create 3 sessions
|
||||
const session1Id = createDbSession('content-multi-1');
|
||||
const session2Id = createDbSession('content-multi-2');
|
||||
const session3Id = createDbSession('content-multi-3');
|
||||
|
||||
// Enqueue different numbers of messages
|
||||
enqueueTestMessage(session1Id, 'content-multi-1');
|
||||
enqueueTestMessage(session1Id, 'content-multi-1'); // 2 messages
|
||||
|
||||
enqueueTestMessage(session2Id, 'content-multi-2'); // 1 message
|
||||
|
||||
// Session 3 has no messages
|
||||
|
||||
// Verify counts
|
||||
expect(pendingStore.getPendingCount(session1Id)).toBe(2);
|
||||
expect(pendingStore.getPendingCount(session2Id)).toBe(1);
|
||||
expect(pendingStore.getPendingCount(session3Id)).toBe(0);
|
||||
|
||||
// getSessionsWithPendingMessages should return session 1 and 2
|
||||
const sessionsWithPending = pendingStore.getSessionsWithPendingMessages();
|
||||
expect(sessionsWithPending).toContain(session1Id);
|
||||
expect(sessionsWithPending).toContain(session2Id);
|
||||
expect(sessionsWithPending).not.toContain(session3Id);
|
||||
expect(sessionsWithPending.length).toBe(2);
|
||||
});
|
||||
|
||||
// Test: AbortController reset before restart
|
||||
test('should reset AbortController when restarting after abort', async () => {
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Abort the controller (simulating a cancelled operation)
|
||||
session.abortController.abort();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
|
||||
// The pattern used in worker-service.ts before starting generator:
|
||||
// if (session.abortController.signal.aborted) {
|
||||
// session.abortController = new AbortController();
|
||||
// }
|
||||
if (session.abortController.signal.aborted) {
|
||||
session.abortController = new AbortController();
|
||||
}
|
||||
|
||||
// New controller should not be aborted
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
// Test: Generator cleanup on session delete
|
||||
test('should properly cleanup generator promise on session delete', async () => {
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Track whether generator was awaited
|
||||
let generatorCompleted = false;
|
||||
|
||||
// Simulate an active generator
|
||||
session.generatorPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
generatorCompleted = true;
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Simulate the deleteSession logic:
|
||||
// 1. Abort the controller
|
||||
session.abortController.abort();
|
||||
|
||||
// 2. Wait for generator to finish
|
||||
if (session.generatorPromise) {
|
||||
await session.generatorPromise.catch(() => {});
|
||||
}
|
||||
|
||||
expect(generatorCompleted).toBe(true);
|
||||
|
||||
// 3. Clear the promise
|
||||
session.generatorPromise = null;
|
||||
expect(session.generatorPromise).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user