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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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