From 5dffb1ebb0348a198bdba504cbcb01880e2d2686 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 6 Feb 2026 03:23:13 -0500 Subject: [PATCH] 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 --- plugin/hooks/hooks.json | 5 ++ src/cli/handlers/index.ts | 16 +++-- src/cli/handlers/session-complete.ts | 62 +++++++++++++++++++ src/services/worker-service.ts | 2 +- .../worker/http/routes/SessionRoutes.ts | 49 +++++++++++++++ 5 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 src/cli/handlers/session-complete.ts diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 2dec4c4b..efdcb2fb 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -80,6 +80,11 @@ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js\" \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" hook claude-code summarize", "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 } ] } diff --git a/src/cli/handlers/index.ts b/src/cli/handlers/index.ts index 0b93919b..cec23070 100644 --- a/src/cli/handlers/index.ts +++ b/src/cli/handlers/index.ts @@ -11,20 +11,23 @@ import { observationHandler } from './observation.js'; import { summarizeHandler } from './summarize.js'; import { userMessageHandler } from './user-message.js'; import { fileEditHandler } from './file-edit.js'; +import { sessionCompleteHandler } from './session-complete.js'; export type EventType = - | 'context' // SessionStart - inject context - | 'session-init' // UserPromptSubmit - initialize session - | 'observation' // PostToolUse - save observation - | 'summarize' // Stop - generate summary - | 'user-message' // SessionStart (parallel) - display to user - | 'file-edit'; // Cursor afterFileEdit + | 'context' // SessionStart - inject context + | 'session-init' // UserPromptSubmit - initialize session + | 'observation' // PostToolUse - save observation + | 'summarize' // Stop - generate summary (phase 1) + | 'session-complete' // Stop - complete session (phase 2) - fixes #842 + | 'user-message' // SessionStart (parallel) - display to user + | 'file-edit'; // Cursor afterFileEdit const handlers: Record = { 'context': contextHandler, 'session-init': sessionInitHandler, 'observation': observationHandler, 'summarize': summarizeHandler, + 'session-complete': sessionCompleteHandler, 'user-message': userMessageHandler, 'file-edit': fileEditHandler }; @@ -51,3 +54,4 @@ export { observationHandler } from './observation.js'; export { summarizeHandler } from './summarize.js'; export { userMessageHandler } from './user-message.js'; export { fileEditHandler } from './file-edit.js'; +export { sessionCompleteHandler } from './session-complete.js'; diff --git a/src/cli/handlers/session-complete.ts b/src/cli/handlers/session-complete.ts new file mode 100644 index 00000000..a5c95fb5 --- /dev/null +++ b/src/cli/handlers/session-complete.ts @@ -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 { + // 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 }; + } +}; diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index d2c63afa..065b82c1 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -908,7 +908,7 @@ async function main() { if (!platform || !event) { console.error('Usage: claude-mem hook '); 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); } diff --git a/src/services/worker/http/routes/SessionRoutes.ts b/src/services/worker/http/routes/SessionRoutes.ts index 8dce97f1..404d49c5 100644 --- a/src/services/worker/http/routes/SessionRoutes.ts +++ b/src/services/worker/http/routes/SessionRoutes.ts @@ -290,6 +290,7 @@ export class SessionRoutes extends BaseRouteHandler { app.post('/api/sessions/init', this.handleSessionInitByClaudeId.bind(this)); app.post('/api/sessions/observations', this.handleObservationsByClaudeId.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' }); }); + /** + * 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 => { + 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) * POST /api/sessions/init