fix: move summary wait + session-complete into Stop hook to prevent lost summaries

SessionEnd has a 1.5s hardcoded cap from Claude Code (CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS),
making it unsuitable for waiting on async work. Previously, the Stop hook would fire-and-forget
the summarize request, then SessionEnd would immediately call deleteSession — aborting the SDK
agent mid-summary.

Now the Stop hook (120s timeout, no cap) owns the full lifecycle:
1. Queue summarize request
2. Poll new GET /api/sessions/status endpoint until queue drains
3. Call /api/sessions/complete after summary finishes

SessionEnd is now a true fire-and-forget fallback (process.exit(0) immediately).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-03 14:05:53 -07:00
parent 8265fc7aa1
commit a2ac116aac
4 changed files with 162 additions and 79 deletions
+2 -2
View File
@@ -74,8 +74,8 @@
"hooks": [
{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
"timeout": 5
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}});r.on('error',()=>{});r.end(JSON.stringify({contentSessionId:s}));process.exit(0)}catch{process.exit(0)}})\"",
"timeout": 2
}
]
}
File diff suppressed because one or more lines are too long
+53 -6
View File
@@ -1,9 +1,16 @@
/**
* Summarize Handler - Stop
*
* Extracted from summary-hook.ts - sends summary request to worker.
* Transcript parsing stays in the hook because only the hook has access to
* the transcript file path.
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
* This is the ONLY place where we can reliably wait for async work.
*
* Flow:
* 1. Queue summarize request to worker
* 2. Poll worker until summary processing completes
* 3. Call /api/sessions/complete to clean up session
*
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
* all real work must happen here in Stop.
*/
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
@@ -13,6 +20,8 @@ import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
const POLL_INTERVAL_MS = 500;
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -47,7 +56,7 @@ export const summarizeHandler: EventHandler = {
hasLastAssistantMessage: !!lastAssistantMessage
});
// Send to worker - worker handles privacy check and database operations
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -59,11 +68,49 @@ export const summarizeHandler: EventHandler = {
});
if (!response.ok) {
// Return standard response even on failure (matches original behavior)
return { continue: true, suppressOutput: true };
}
logger.debug('HOOK', 'Summary request sent successfully');
logger.debug('HOOK', 'Summary request queued, waiting for completion');
// 2. Poll worker until pending work for this session is done.
// This keeps the Stop hook alive (120s timeout) so the SDK agent
// can finish processing the summary before SessionEnd kills the session.
const waitStart = Date.now();
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
timeoutMs: 5000
});
if (statusResponse.ok) {
const status = await statusResponse.json() as { queueLength?: number };
if ((status.queueLength ?? 0) === 0) {
logger.info('HOOK', 'Summary processing complete', {
waitedMs: Date.now() - waitStart
});
break;
}
}
} catch {
// Worker may be busy — keep polling
}
}
// 3. Complete the session — clean up active sessions map.
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
// so it reliably fires after summary work is done.
try {
await workerHttpRequest('/api/sessions/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentSessionId: sessionId }),
timeoutMs: 10_000
});
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
} catch (err) {
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
}
return { continue: true, suppressOutput: true };
}
@@ -321,6 +321,7 @@ export class SessionRoutes extends BaseRouteHandler {
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));
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
}
/**
@@ -631,6 +632,39 @@ export class SessionRoutes extends BaseRouteHandler {
res.json({ status: 'queued' });
});
/**
* Get session status by contentSessionId (summarize handler polls this)
* GET /api/sessions/status?contentSessionId=...
*
* Returns queue depth so the Stop hook can wait for summary completion.
*/
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const contentSessionId = req.query.contentSessionId as string;
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId query parameter');
}
const store = this.dbManager.getSessionStore();
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const session = this.sessionManager.getSession(sessionDbId);
if (!session) {
res.json({ status: 'not_found', queueLength: 0 });
return;
}
const pendingStore = this.sessionManager.getPendingMessageStore();
const queueLength = pendingStore.getPendingCount(sessionDbId);
res.json({
status: 'active',
sessionDbId,
queueLength,
uptime: Date.now() - session.startTime
});
});
/**
* Complete session by contentSessionId (session-complete hook uses this)
* POST /api/sessions/complete
@@ -669,6 +703,8 @@ export class SessionRoutes extends BaseRouteHandler {
}
// Complete the session (removes from active sessions map)
// Note: The Stop hook (summarize handler) waits for pending work before calling
// this endpoint. No polling here — that's the hook's responsibility.
await this.completionHandler.completeByDbId(sessionDbId);
logger.info('SESSION', 'Session completed via API', {