MAESTRO: fix(hooks): add session-complete handler to enable orphan reaper cleanup
Cherry-picked from PR #844 by @thusdigital. Sessions stayed in active sessions map forever after summarize, causing the orphan reaper to think all processes were still active. Adds session-complete as Stop phase 2 hook that calls POST /api/sessions/complete to remove sessions from the active map, allowing the reaper to correctly identify and clean up orphaned worker processes. Fixes #842. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,11 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code summarize",
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code summarize",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code session-complete",
|
||||||
|
"timeout": 30
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,23 @@ import { observationHandler } from './observation.js';
|
|||||||
import { summarizeHandler } from './summarize.js';
|
import { summarizeHandler } from './summarize.js';
|
||||||
import { userMessageHandler } from './user-message.js';
|
import { userMessageHandler } from './user-message.js';
|
||||||
import { fileEditHandler } from './file-edit.js';
|
import { fileEditHandler } from './file-edit.js';
|
||||||
|
import { sessionCompleteHandler } from './session-complete.js';
|
||||||
|
|
||||||
export type EventType =
|
export type EventType =
|
||||||
| 'context' // SessionStart - inject context
|
| 'context' // SessionStart - inject context
|
||||||
| 'session-init' // UserPromptSubmit - initialize session
|
| 'session-init' // UserPromptSubmit - initialize session
|
||||||
| 'observation' // PostToolUse - save observation
|
| 'observation' // PostToolUse - save observation
|
||||||
| 'summarize' // Stop - generate summary
|
| 'summarize' // Stop - generate summary (phase 1)
|
||||||
| 'user-message' // SessionStart (parallel) - display to user
|
| 'session-complete' // Stop - complete session (phase 2) - fixes #842
|
||||||
| 'file-edit'; // Cursor afterFileEdit
|
| 'user-message' // SessionStart (parallel) - display to user
|
||||||
|
| 'file-edit'; // Cursor afterFileEdit
|
||||||
|
|
||||||
const handlers: Record<EventType, EventHandler> = {
|
const handlers: Record<EventType, EventHandler> = {
|
||||||
'context': contextHandler,
|
'context': contextHandler,
|
||||||
'session-init': sessionInitHandler,
|
'session-init': sessionInitHandler,
|
||||||
'observation': observationHandler,
|
'observation': observationHandler,
|
||||||
'summarize': summarizeHandler,
|
'summarize': summarizeHandler,
|
||||||
|
'session-complete': sessionCompleteHandler,
|
||||||
'user-message': userMessageHandler,
|
'user-message': userMessageHandler,
|
||||||
'file-edit': fileEditHandler
|
'file-edit': fileEditHandler
|
||||||
};
|
};
|
||||||
@@ -51,3 +54,4 @@ export { observationHandler } from './observation.js';
|
|||||||
export { summarizeHandler } from './summarize.js';
|
export { summarizeHandler } from './summarize.js';
|
||||||
export { userMessageHandler } from './user-message.js';
|
export { userMessageHandler } from './user-message.js';
|
||||||
export { fileEditHandler } from './file-edit.js';
|
export { fileEditHandler } from './file-edit.js';
|
||||||
|
export { sessionCompleteHandler } from './session-complete.js';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Session Complete Handler - Stop (Phase 2)
|
||||||
|
*
|
||||||
|
* Completes the session after summarize has been queued.
|
||||||
|
* This removes the session from the active sessions map, allowing
|
||||||
|
* the orphan reaper to clean up any remaining subprocess.
|
||||||
|
*
|
||||||
|
* Fixes Issue #842: Orphan reaper starts but never reaps because
|
||||||
|
* sessions stay in the active sessions map forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
|
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
export const sessionCompleteHandler: EventHandler = {
|
||||||
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||||
|
// Ensure worker is running
|
||||||
|
await ensureWorkerRunning();
|
||||||
|
|
||||||
|
const { sessionId } = input;
|
||||||
|
const port = getWorkerPort();
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('HOOK', '→ session-complete: Removing session from active map', {
|
||||||
|
workerPort: port,
|
||||||
|
contentSessionId: sessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the session complete endpoint by contentSessionId
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contentSessionId: sessionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
logger.warn('HOOK', 'session-complete: Failed to complete session', {
|
||||||
|
status: response.status,
|
||||||
|
body: text
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail - session may already be gone
|
||||||
|
logger.warn('HOOK', 'session-complete: Error completing session', {
|
||||||
|
error: (error as Error).message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -908,7 +908,7 @@ async function main() {
|
|||||||
if (!platform || !event) {
|
if (!platform || !event) {
|
||||||
console.error('Usage: claude-mem hook <platform> <event>');
|
console.error('Usage: claude-mem hook <platform> <event>');
|
||||||
console.error('Platforms: claude-code, cursor, raw');
|
console.error('Platforms: claude-code, cursor, raw');
|
||||||
console.error('Events: context, session-init, observation, summarize');
|
console.error('Events: context, session-init, observation, summarize, session-complete');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
app.post('/api/sessions/init', this.handleSessionInitByClaudeId.bind(this));
|
app.post('/api/sessions/init', this.handleSessionInitByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
||||||
|
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -594,6 +595,54 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
res.json({ status: 'queued' });
|
res.json({ status: 'queued' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete session by contentSessionId (session-complete hook uses this)
|
||||||
|
* POST /api/sessions/complete
|
||||||
|
* Body: { contentSessionId }
|
||||||
|
*
|
||||||
|
* Removes session from active sessions map, allowing orphan reaper to
|
||||||
|
* clean up any remaining subprocesses.
|
||||||
|
*
|
||||||
|
* Fixes Issue #842: Sessions stay in map forever, reaper thinks all active.
|
||||||
|
*/
|
||||||
|
private handleCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { contentSessionId } = req.body;
|
||||||
|
|
||||||
|
logger.info('HTTP', '→ POST /api/sessions/complete', { contentSessionId });
|
||||||
|
|
||||||
|
if (!contentSessionId) {
|
||||||
|
return this.badRequest(res, 'Missing contentSessionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.dbManager.getSessionStore();
|
||||||
|
|
||||||
|
// Look up sessionDbId from contentSessionId (createSDKSession is idempotent)
|
||||||
|
// Pass empty strings - we only need the ID lookup, not to create a new session
|
||||||
|
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||||
|
|
||||||
|
// Check if session is in the active sessions map
|
||||||
|
const activeSession = this.sessionManager.getSession(sessionDbId);
|
||||||
|
if (!activeSession) {
|
||||||
|
// Session may not be in memory (already completed or never initialized)
|
||||||
|
logger.debug('SESSION', 'session-complete: Session not in active map', {
|
||||||
|
contentSessionId,
|
||||||
|
sessionDbId
|
||||||
|
});
|
||||||
|
res.json({ status: 'skipped', reason: 'not_active' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the session (removes from active sessions map)
|
||||||
|
await this.completionHandler.completeByDbId(sessionDbId);
|
||||||
|
|
||||||
|
logger.info('SESSION', 'Session completed via API', {
|
||||||
|
contentSessionId,
|
||||||
|
sessionDbId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'completed', sessionDbId });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize session by contentSessionId (new-hook uses this)
|
* Initialize session by contentSessionId (new-hook uses this)
|
||||||
* POST /api/sessions/init
|
* POST /api/sessions/init
|
||||||
|
|||||||
Reference in New Issue
Block a user