Compare commits

...

7 Commits

Author SHA1 Message Date
Alex Newman 2aae3d9db5 chore(release): v8.5.2 - Fix SDK agent memory leak 2025-12-31 16:51:45 -05:00
Alex Newman de20eb65b5 Enhance session handling in SessionRoutes
- Improved logging for session aborts and unexpected exits.
- Introduced a variable to track if the session was aborted for clarity.
- Added logic to create a new AbortController when restarting the generator after a crash.
- Implemented a mechanism to abort the session if there are no pending tasks after a natural completion.
- Ensured that errors during recovery checks lead to session abortion to prevent resource leaks.
2025-12-31 16:49:17 -05:00
Alex Newman fef332d213 feat: add examples for Claude Agent SDK V2 including basic, multi-turn, one-shot, and session resume functionalities 2025-12-31 16:37:49 -05:00
Alex Newman 3b86d5ccad docs: update CHANGELOG.md for v8.5.1 release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:31:21 -05:00
Alex Newman e95cfca9aa chore(release): v8.5.1 - Migration 17 idempotency fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:30:43 -05:00
Lindsey Catlett d9e966d8f4 fix: make migration 17 fully idempotent for databases in intermediate states (#481)
* fix: make migration 17 idempotent and standardize column names

Migration 17 renamed columns from sdk_session_id to memory_session_id,
but the migration wasn't fully idempotent - it could fail on databases
in intermediate states. This caused errors like:
- "no such column: sdk_session_id" (when columns already renamed)
- "table observations has no column named memory_session_id" (when not renamed)

Changes:
- Rewrite renameSessionIdColumns() to check each table individually
- Use safeRenameColumn() helper that handles all edge cases gracefully
- Deprecate migration 19 (repair migration) since 17 is now idempotent
- Update maintenance scripts to use memory_session_id column name
- Update test files to use new column names

Fixes column mismatch bug in v8.2.6+

* Merge origin/main into column-mismatch

---------

Co-authored-by: Alex Newman <thedotmack@gmail.com>
2025-12-29 23:30:04 -05:00
Alex Newman 61a23a14a9 docs: update CHANGELOG.md for v8.5.0 release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:13:53 -05:00
18 changed files with 704 additions and 480 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "8.5.0",
"version": "8.5.2",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+409 -283
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
/**
* Claude Agent SDK V2 Examples
*
* The V2 API provides a session-based interface with separate send()/receive(),
* ideal for multi-turn conversations. Run with: npx tsx v2-examples.ts
*/
import {
unstable_v2_createSession,
unstable_v2_resumeSession,
unstable_v2_prompt,
} from '@anthropic-ai/claude-agent-sdk';
async function main() {
const example = process.argv[2] || 'basic';
switch (example) {
case 'basic':
await basicSession();
break;
case 'multi-turn':
await multiTurn();
break;
case 'one-shot':
await oneShot();
break;
case 'resume':
await sessionResume();
break;
default:
console.log('Usage: npx tsx v2-examples.ts [basic|multi-turn|one-shot|resume]');
}
}
// Basic session with send/receive pattern
async function basicSession() {
console.log('=== Basic Session ===\n');
await using session = unstable_v2_createSession({ model: 'sonnet' });
await session.send('Hello! Introduce yourself in one sentence.');
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text');
console.log(`Claude: ${text?.text}`);
}
}
}
// Multi-turn conversation - V2's key advantage
async function multiTurn() {
console.log('=== Multi-Turn Conversation ===\n');
await using session = unstable_v2_createSession({ model: 'sonnet' });
// Turn 1
await session.send('What is 5 + 3? Just the number.');
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text');
console.log(`Turn 1: ${text?.text}`);
}
}
// Turn 2 - Claude remembers context
await session.send('Multiply that by 2. Just the number.');
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text');
console.log(`Turn 2: ${text?.text}`);
}
}
}
// One-shot convenience function
async function oneShot() {
console.log('=== One-Shot Prompt ===\n');
const result = await unstable_v2_prompt('What is the capital of France? One word.', { model: 'sonnet' });
if (result.subtype === 'success') {
console.log(`Answer: ${result.result}`);
console.log(`Cost: $${result.total_cost_usd.toFixed(4)}`);
}
}
// Session resume - persist context across sessions
async function sessionResume() {
console.log('=== Session Resume ===\n');
let sessionId: string | undefined;
// First session - establish a memory
{
await using session = unstable_v2_createSession({ model: 'sonnet' });
console.log('[Session 1] Telling Claude my favorite color...');
await session.send('My favorite color is blue. Remember this!');
for await (const msg of session.receive()) {
if (msg.type === 'system' && msg.subtype === 'init') {
sessionId = msg.session_id;
console.log(`[Session 1] ID: ${sessionId}`);
}
if (msg.type === 'assistant') {
const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text');
console.log(`[Session 1] Claude: ${text?.text}\n`);
}
}
}
console.log('--- Session closed. Time passes... ---\n');
// Resume and verify Claude remembers
{
await using session = unstable_v2_resumeSession(sessionId!, { model: 'sonnet' });
console.log('[Session 2] Resuming and asking Claude...');
await session.send('What is my favorite color?');
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content.find((c): c is { type: 'text'; text: string } => c.type === 'text');
console.log(`[Session 2] Claude: ${text?.text}`);
}
}
}
}
main().catch(console.error);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "8.5.0",
"version": "8.5.2",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "8.5.0",
"version": "8.5.2",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "8.5.0",
"version": "8.5.1",
"private": true,
"description": "Runtime dependencies for claude-mem bundled hooks",
"type": "module",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -12,7 +12,7 @@ import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
interface ObservationRecord {
id: number;
sdk_session_id: string;
memory_session_id: string;
project: string;
text: string | null;
type: string;
@@ -31,8 +31,8 @@ interface ObservationRecord {
interface SdkSessionRecord {
id: number;
claude_session_id: string;
sdk_session_id: string;
content_session_id: string;
memory_session_id: string;
project: string;
user_prompt: string;
started_at: string;
@@ -44,7 +44,7 @@ interface SdkSessionRecord {
interface SessionSummaryRecord {
id: number;
sdk_session_id: string;
memory_session_id: string;
project: string;
request: string | null;
investigated: string | null;
@@ -62,7 +62,7 @@ interface SessionSummaryRecord {
interface UserPromptRecord {
id: number;
claude_session_id: string;
content_session_id: string;
prompt_number: number;
prompt_text: string;
created_at: string;
@@ -117,23 +117,23 @@ async function exportMemories(query: string, outputFile: string, project?: strin
console.log(`✅ Found ${summaries.length} session summaries`);
console.log(`✅ Found ${prompts.length} user prompts`);
// Get unique SDK session IDs from observations and summaries
const sdkSessionIds = new Set<string>();
// Get unique memory session IDs from observations and summaries
const memorySessionIds = new Set<string>();
observations.forEach((o) => {
if (o.sdk_session_id) sdkSessionIds.add(o.sdk_session_id);
if (o.memory_session_id) memorySessionIds.add(o.memory_session_id);
});
summaries.forEach((s) => {
if (s.sdk_session_id) sdkSessionIds.add(s.sdk_session_id);
if (s.memory_session_id) memorySessionIds.add(s.memory_session_id);
});
// Get SDK sessions metadata via API
console.log('📡 Fetching SDK sessions metadata...');
let sessions: SdkSessionRecord[] = [];
if (sdkSessionIds.size > 0) {
if (memorySessionIds.size > 0) {
const sessionsResponse = await fetch(`${baseUrl}/api/sdk-sessions/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sdkSessionIds: Array.from(sdkSessionIds) })
body: JSON.stringify({ sdkSessionIds: Array.from(memorySessionIds) })
});
if (sessionsResponse.ok) {
sessions = await sessionsResponse.json();
+3 -3
View File
@@ -18,7 +18,7 @@ interface CorruptedObservation {
obs_created: number;
session_started: number;
session_completed: number | null;
sdk_session_id: string;
memory_session_id: string;
}
function formatTimestamp(epoch: number): string {
@@ -54,9 +54,9 @@ function main() {
o.created_at_epoch as obs_created,
s.started_at_epoch as session_started,
s.completed_at_epoch as session_completed,
s.sdk_session_id
s.memory_session_id
FROM observations o
JOIN sdk_sessions s ON o.sdk_session_id = s.sdk_session_id
JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE o.created_at_epoch < s.started_at_epoch -- Observation older than session
OR (s.completed_at_epoch IS NOT NULL
AND o.created_at_epoch > (s.completed_at_epoch + 3600000)) -- More than 1hr after session
+6 -6
View File
@@ -20,7 +20,7 @@ const BAD_WINDOW_END = 1766626260000; // Dec 24 20:31 PST
interface AffectedObservation {
id: number;
sdk_session_id: string;
memory_session_id: string;
created_at_epoch: number;
title: string;
}
@@ -35,7 +35,7 @@ interface ProcessedMessage {
interface SessionMapping {
session_db_id: number;
sdk_session_id: string;
memory_session_id: string;
}
interface TimestampFix {
@@ -75,7 +75,7 @@ function main() {
// Step 1: Find affected observations
console.log('Step 1: Finding observations created during bad window...');
const affectedObs = db.query<AffectedObservation, []>(`
SELECT id, sdk_session_id, created_at_epoch, title
SELECT id, memory_session_id, created_at_epoch, title
FROM observations
WHERE created_at_epoch >= ${BAD_WINDOW_START}
AND created_at_epoch <= ${BAD_WINDOW_END}
@@ -111,7 +111,7 @@ function main() {
obs_title: string;
obs_created: number;
session_started: number;
sdk_session_id: string;
memory_session_id: string;
}
const obsWithSessions = db.query<ObsWithSession, []>(`
@@ -120,9 +120,9 @@ function main() {
o.title as obs_title,
o.created_at_epoch as obs_created,
s.started_at_epoch as session_started,
s.sdk_session_id
s.memory_session_id
FROM observations o
JOIN sdk_sessions s ON o.sdk_session_id = s.sdk_session_id
JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE o.created_at_epoch >= ${BAD_WINDOW_START}
AND o.created_at_epoch <= ${BAD_WINDOW_END}
AND s.started_at_epoch < ${BAD_WINDOW_START}
+2 -2
View File
@@ -36,7 +36,7 @@ function main() {
const dec24End = 1735113600000; // Dec 25 00:00 PST
const dec24Obs = db.query(`
SELECT id, sdk_session_id, created_at_epoch, title
SELECT id, memory_session_id, created_at_epoch, title
FROM observations
WHERE created_at_epoch >= ${dec24Start}
AND created_at_epoch < ${dec24End}
@@ -59,7 +59,7 @@ function main() {
const dec21Start = 1734768000000; // Dec 21 00:00 PST
const oldObs = db.query(`
SELECT id, sdk_session_id, created_at_epoch, title
SELECT id, memory_session_id, created_at_epoch, title
FROM observations
WHERE created_at_epoch >= ${dec17Start}
AND created_at_epoch < ${dec21Start}
+1 -1
View File
@@ -59,7 +59,7 @@ function main() {
pm.tool_name,
pm.created_at_epoch as msg_created,
pm.status,
s.sdk_session_id,
s.memory_session_id,
s.started_at_epoch as session_started,
s.project
FROM pending_messages pm
+7 -7
View File
@@ -22,7 +22,7 @@ const ORIGINAL_WINDOW_END = 1766613600000; // Dec 23 23:59 PST
interface Observation {
id: number;
sdk_session_id: string;
memory_session_id: string;
created_at_epoch: number;
created_at: string;
title: string;
@@ -49,7 +49,7 @@ function main() {
// Check 1: Observations still in bad window
console.log('Check 1: Looking for observations still in bad window (Dec 24 19:45-20:31)...');
const badWindowObs = db.query<Observation, []>(`
SELECT id, sdk_session_id, created_at_epoch, created_at, title
SELECT id, memory_session_id, created_at_epoch, created_at, title
FROM observations
WHERE created_at_epoch >= ${BAD_WINDOW_START}
AND created_at_epoch <= ${BAD_WINDOW_END}
@@ -63,7 +63,7 @@ function main() {
for (const obs of badWindowObs) {
console.log(` Observation #${obs.id}: ${obs.title || '(no title)'}`);
console.log(` Timestamp: ${formatTimestamp(obs.created_at_epoch)}`);
console.log(` Session: ${obs.sdk_session_id}\n`);
console.log(` Session: ${obs.memory_session_id}\n`);
}
}
@@ -81,19 +81,19 @@ function main() {
// Check 3: Session distribution
console.log('Check 3: Session distribution of corrected observations...');
const sessionDist = db.query<{ sdk_session_id: string; count: number }, []>(`
SELECT sdk_session_id, COUNT(*) as count
const sessionDist = db.query<{ memory_session_id: string; count: number }, []>(`
SELECT memory_session_id, COUNT(*) as count
FROM observations
WHERE created_at_epoch >= ${ORIGINAL_WINDOW_START}
AND created_at_epoch <= ${ORIGINAL_WINDOW_END}
GROUP BY sdk_session_id
GROUP BY memory_session_id
ORDER BY count DESC
`).all();
if (sessionDist.length > 0) {
console.log(`Observations distributed across ${sessionDist.length} sessions:\n`);
for (const dist of sessionDist.slice(0, 10)) {
console.log(` ${dist.sdk_session_id}: ${dist.count} observations`);
console.log(` ${dist.memory_session_id}: ${dist.count} observations`);
}
if (sessionDist.length > 10) {
console.log(` ... and ${sessionDist.length - 10} more sessions`);
+55 -96
View File
@@ -582,126 +582,85 @@ export class SessionStore {
* Rename session ID columns for semantic clarity (migration 17)
* - claude_session_id content_session_id (user's observed session)
* - sdk_session_id memory_session_id (memory agent's session for resume)
*
* IDEMPOTENT: Checks each table individually before renaming.
* This handles databases in any intermediate state (partial migration, fresh install, etc.)
*/
private renameSessionIdColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(17) as SchemaVersion | undefined;
if (applied) return;
// Check if columns are already renamed (idempotent check)
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasContentSessionId = sessionsInfo.some(col => col.name === 'content_session_id');
logger.info('DB', 'Checking session ID columns for semantic clarity rename');
if (hasContentSessionId) {
// Already renamed, just record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(17, new Date().toISOString());
return;
}
let renamesPerformed = 0;
logger.info('DB', 'Renaming session ID columns for semantic clarity');
// Helper to safely rename a column if it exists
const safeRenameColumn = (table: string, oldCol: string, newCol: string): boolean => {
try {
const tableInfo = this.db.query(`PRAGMA table_info(${table})`).all() as TableColumnInfo[];
const hasOldCol = tableInfo.some(col => col.name === oldCol);
const hasNewCol = tableInfo.some(col => col.name === newCol);
// Begin transaction for atomic rename
this.db.run('BEGIN TRANSACTION');
if (hasNewCol) {
// Already renamed, nothing to do
return false;
}
try {
// SQLite 3.25+ supports ALTER TABLE RENAME COLUMN
// Rename in sdk_sessions table
this.db.run('ALTER TABLE sdk_sessions RENAME COLUMN claude_session_id TO content_session_id');
this.db.run('ALTER TABLE sdk_sessions RENAME COLUMN sdk_session_id TO memory_session_id');
if (hasOldCol) {
// SQLite 3.25+ supports ALTER TABLE RENAME COLUMN
this.db.run(`ALTER TABLE ${table} RENAME COLUMN ${oldCol} TO ${newCol}`);
logger.info('DB', `Renamed ${table}.${oldCol} to ${newCol}`);
return true;
}
// Rename in pending_messages table
this.db.run('ALTER TABLE pending_messages RENAME COLUMN claude_session_id TO content_session_id');
// Neither column exists - table might not exist or has different schema
logger.warn('DB', `Column ${oldCol} not found in ${table}, skipping rename`);
return false;
} catch (error: any) {
// Table might not exist yet, which is fine
logger.warn('DB', `Could not rename ${table}.${oldCol}: ${error.message}`);
return false;
}
};
// Rename in observations table
this.db.run('ALTER TABLE observations RENAME COLUMN sdk_session_id TO memory_session_id');
// Rename in sdk_sessions table
if (safeRenameColumn('sdk_sessions', 'claude_session_id', 'content_session_id')) renamesPerformed++;
if (safeRenameColumn('sdk_sessions', 'sdk_session_id', 'memory_session_id')) renamesPerformed++;
// Rename in session_summaries table
this.db.run('ALTER TABLE session_summaries RENAME COLUMN sdk_session_id TO memory_session_id');
// Rename in pending_messages table
if (safeRenameColumn('pending_messages', 'claude_session_id', 'content_session_id')) renamesPerformed++;
// Rename in user_prompts table
this.db.run('ALTER TABLE user_prompts RENAME COLUMN claude_session_id TO content_session_id');
// Rename in observations table
if (safeRenameColumn('observations', 'sdk_session_id', 'memory_session_id')) renamesPerformed++;
// Commit transaction
this.db.run('COMMIT');
// Rename in session_summaries table
if (safeRenameColumn('session_summaries', 'sdk_session_id', 'memory_session_id')) renamesPerformed++;
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(17, new Date().toISOString());
// Rename in user_prompts table
if (safeRenameColumn('user_prompts', 'claude_session_id', 'content_session_id')) renamesPerformed++;
logger.info('DB', 'Successfully renamed session ID columns');
} catch (error: any) {
// Rollback on error
this.db.run('ROLLBACK');
logger.error('DB', 'Session ID column rename migration error', undefined, error);
throw error;
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(17, new Date().toISOString());
if (renamesPerformed > 0) {
logger.info('DB', `Successfully renamed ${renamesPerformed} session ID columns`);
} else {
logger.info('DB', 'No session ID column renames needed (already up to date)');
}
}
/**
* Repair session ID column renames (migration 19)
* Migration 17 may have been recorded but failed to actually rename columns.
* This migration checks each table and renames if needed (idempotent).
* DEPRECATED: Migration 17 is now fully idempotent and handles all cases.
* This migration is kept for backwards compatibility but does nothing.
*/
private repairSessionIdColumnRename(): void {
try {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(19) as SchemaVersion | undefined;
if (applied) return;
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(19) as SchemaVersion | undefined;
if (applied) return;
logger.info('DB', 'Checking session ID column renames (repair migration)');
let repairsNeeded = false;
// Check and fix sdk_sessions
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
if (sessionsInfo.some(col => col.name === 'claude_session_id')) {
logger.info('DB', 'Repairing sdk_sessions columns');
this.db.run('ALTER TABLE sdk_sessions RENAME COLUMN claude_session_id TO content_session_id');
this.db.run('ALTER TABLE sdk_sessions RENAME COLUMN sdk_session_id TO memory_session_id');
repairsNeeded = true;
}
// Check and fix pending_messages
const pendingInfo = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
if (pendingInfo.some(col => col.name === 'claude_session_id')) {
logger.info('DB', 'Repairing pending_messages columns');
this.db.run('ALTER TABLE pending_messages RENAME COLUMN claude_session_id TO content_session_id');
repairsNeeded = true;
}
// Check and fix observations
const obsInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
if (obsInfo.some(col => col.name === 'sdk_session_id')) {
logger.info('DB', 'Repairing observations columns');
this.db.run('ALTER TABLE observations RENAME COLUMN sdk_session_id TO memory_session_id');
repairsNeeded = true;
}
// Check and fix session_summaries
const summariesInfo = this.db.query('PRAGMA table_info(session_summaries)').all() as TableColumnInfo[];
if (summariesInfo.some(col => col.name === 'sdk_session_id')) {
logger.info('DB', 'Repairing session_summaries columns');
this.db.run('ALTER TABLE session_summaries RENAME COLUMN sdk_session_id TO memory_session_id');
repairsNeeded = true;
}
// Check and fix user_prompts
const promptsInfo = this.db.query('PRAGMA table_info(user_prompts)').all() as TableColumnInfo[];
if (promptsInfo.some(col => col.name === 'claude_session_id')) {
logger.info('DB', 'Repairing user_prompts columns');
this.db.run('ALTER TABLE user_prompts RENAME COLUMN claude_session_id TO content_session_id');
repairsNeeded = true;
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(19, new Date().toISOString());
if (repairsNeeded) {
logger.info('DB', 'Session ID column rename repairs completed');
} else {
logger.info('DB', 'No session ID column repairs needed');
}
} catch (error: any) {
logger.error('DB', 'Session ID column rename repair error', undefined, error);
throw error;
}
// Migration 17 now handles all column rename cases idempotently.
// Just record this migration as applied.
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(19, new Date().toISOString());
}
/**
@@ -168,8 +168,9 @@ export class SessionRoutes extends BaseRouteHandler {
})
.finally(() => {
const sessionDbId = session.sessionDbId;
if (session.abortController.signal.aborted) {
const wasAborted = session.abortController.signal.aborted;
if (wasAborted) {
logger.info('SESSION', `Generator aborted`, { sessionId: sessionDbId });
} else {
logger.warn('SESSION', `Generator exited unexpectedly`, { sessionId: sessionDbId });
@@ -180,16 +181,20 @@ export class SessionRoutes extends BaseRouteHandler {
this.workerService.broadcastProcessingStatus();
// Crash recovery: If not aborted and still has work, restart
if (!session.abortController.signal.aborted) {
if (!wasAborted) {
try {
const pendingStore = this.sessionManager.getPendingMessageStore();
const pendingCount = pendingStore.getPendingCount(sessionDbId);
if (pendingCount > 0) {
logger.info('SESSION', `Restarting generator after crash/exit with pending work`, {
sessionId: sessionDbId,
pendingCount
});
// Create new AbortController for the restarted generator
session.abortController = new AbortController();
// Small delay before restart
setTimeout(() => {
const stillExists = this.sessionManager.getSession(sessionDbId);
@@ -197,12 +202,19 @@ export class SessionRoutes extends BaseRouteHandler {
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
}
}, 1000);
} else {
// No pending work - abort to kill the child process
session.abortController.abort();
logger.debug('SESSION', 'Aborted controller after natural completion', {
sessionId: sessionDbId
});
}
} catch (e) {
// Ignore errors during recovery check
// Ignore errors during recovery check, but still abort to prevent leaks
session.abortController.abort();
}
}
// NOTE: We do NOT delete the session here anymore.
// NOTE: We do NOT delete the session here anymore.
// The generator waits for events, so if it exited, it's either aborted or crashed.
// Idle sessions stay in memory (ActiveSession is small) to listen for future events.
});
+3 -3
View File
@@ -37,9 +37,9 @@ describe('SessionStore', () => {
const claudeId = 'claude-sess-obs';
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
// Get the sdk_session_id string (createSDKSession returns number ID, need string for FK)
// Wait, createSDKSession inserts using sdk_session_id = claude_session_id in the current implementation
// "VALUES (?, ?, ?, ?, ?, ?, 'active')" -> claudeSessionId, claudeSessionId, ...
// Get the memory_session_id string (createSDKSession returns number ID, need string for FK)
// createSDKSession inserts using memory_session_id = content_session_id in the current implementation
// "VALUES (?, ?, ?, ?, ?, ?, 'active')" -> contentSessionId, contentSessionId, ...
const obs = {
type: 'discovery',
+17 -18
View File
@@ -7,11 +7,12 @@ describe('Refactor Validation: SQL Updates', () => {
beforeEach(() => {
db = new Database(':memory:');
// Minimal schema for sdk_sessions based on SessionStore.ts migration004
// Uses new column names: content_session_id and memory_session_id
db.run(`
CREATE TABLE sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT UNIQUE,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT,
@@ -27,28 +28,26 @@ describe('Refactor Validation: SQL Updates', () => {
db.close();
});
it('should update sdk_session_id using direct SQL (replacing updateSDKSessionId)', () => {
// Setup initial state: A session without an sdk_session_id
const claudeId = 'claude-session-123';
const syntheticId = 'sdk-session-456';
it('should update memory_session_id using direct SQL (replacing updateSDKSessionId)', () => {
// Setup initial state: A session without a memory_session_id
const contentId = 'content-session-123';
const memoryId = 'memory-session-456';
db.prepare(`
INSERT INTO sdk_sessions (claude_session_id, project, started_at, started_at_epoch)
INSERT INTO sdk_sessions (content_session_id, project, started_at, started_at_epoch)
VALUES (?, ?, ?, ?)
`).run(claudeId, 'test-project', '2025-01-01T00:00:00Z', 1735689600000);
`).run(contentId, 'test-project', '2025-01-01T00:00:00Z', 1735689600000);
// Verify initial state
const before = db.prepare('SELECT sdk_session_id FROM sdk_sessions WHERE claude_session_id = ?').get(claudeId) as any;
expect(before.sdk_session_id).toBeNull();
const before = db.prepare('SELECT memory_session_id FROM sdk_sessions WHERE content_session_id = ?').get(contentId) as any;
expect(before.memory_session_id).toBeNull();
// EXECUTE: The exact SQL statement from the refactor in import-xml-observations.ts
// Original code: db['db'].prepare('UPDATE sdk_sessions SET sdk_session_id = ? WHERE claude_session_id = ?').run(syntheticSdkSessionId, sessionMeta.sessionId);
const stmt = db.prepare('UPDATE sdk_sessions SET sdk_session_id = ? WHERE claude_session_id = ?');
stmt.run(syntheticId, claudeId);
// EXECUTE: The exact SQL statement from the refactor
const stmt = db.prepare('UPDATE sdk_sessions SET memory_session_id = ? WHERE content_session_id = ?');
stmt.run(memoryId, contentId);
// VERIFY: The update happened
const after = db.prepare('SELECT sdk_session_id FROM sdk_sessions WHERE claude_session_id = ?').get(claudeId) as any;
expect(after.sdk_session_id).toBe(syntheticId);
const after = db.prepare('SELECT memory_session_id FROM sdk_sessions WHERE content_session_id = ?').get(contentId) as any;
expect(after.memory_session_id).toBe(memoryId);
});
});