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:
Alex Newman
2026-02-06 03:16:17 -05:00
parent 7ed1e576b2
commit da1d2cd36a
20 changed files with 1136 additions and 150 deletions
+139
View 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);
});
});
+18 -8
View File
@@ -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({
+32 -11
View File
@@ -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();
});
});
+1
View File
@@ -7,6 +7,7 @@ mock.module('../../src/utils/logger.js', () => ({
debug: () => {},
warn: () => {},
error: () => {},
formatTool: (toolName: string, toolInput?: any) => toolInput ? `${toolName}(...)` : toolName,
},
}));
+1
View File
@@ -10,6 +10,7 @@ mock.module('../../src/utils/logger.js', () => ({
debug: () => {},
warn: () => {},
error: () => {},
formatTool: (toolName: string, toolInput?: any) => toolInput ? `${toolName}(...)` : toolName,
},
}));
+132 -57
View File
@@ -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');
});
+19 -1
View File
@@ -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
View 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();
});
});