fix: stop draining queue on /clear (remove SessionEnd shim) (#2136)

* fix: stop draining queue on /clear (and on every other SessionEnd)

The SessionEnd hook was wired to session-complete on Claude Code, Gemini
CLI, the transcripts processor, the OpenCode plugin, and OpenClaw. All of
those paths called POST /api/sessions/complete, which marked the session
completed and abandoned every still-pending observation in the queue.

So typing /clear (or logging out, or quitting) wiped in-flight work that
the worker was perfectly happy to keep processing on its own.

Removed the entire shim:
- Deleted SessionEnd hook block in plugin/hooks/hooks.json
- Deleted src/cli/handlers/session-complete.ts and its registry entry
- Deleted POST /api/sessions/complete route + Zod schema in SessionRoutes
- Removed call from transcripts processor handleSessionEnd
- Removed call from opencode-plugin session.deleted handler
- Removed Gemini SessionEnd → session-complete mapping
- Removed openclaw scheduleSessionComplete + completionDelayMs + timer state
- Updated tests + comments accordingly

Explicit user-initiated deletion (DELETE /api/sessions/:id and
POST /api/sessions/:sessionDbId/complete from the viewer UI) still works
via SessionCompletionHandler.completeByDbId — that's the only path that
should drain the queue.

The worker self-completes via its SDK-agent generator's finally-block, so
no external completion call is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: clarify opencode-plugin session.deleted is in-memory cleanup only

Greptile P2: file-level header still implied session.deleted called the
worker. Now it only cleans up the local contentSessionIdsByOpenCodeSessionId
map; worker self-completes via the SDK-agent generator finally-block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-25 17:08:35 -07:00
committed by GitHub
parent 298f5463d9
commit 8e0e3ca109
17 changed files with 540 additions and 695 deletions
@@ -86,7 +86,6 @@ const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
'AfterTool': 'observation',
'PreCompress': 'summarize',
'Notification': 'observation',
'SessionEnd': 'session-complete',
};
// ============================================================================
-6
View File
@@ -1,7 +1,6 @@
import path from 'path';
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { DATA_DIR } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
@@ -339,11 +338,6 @@ export class TranscriptEventProcessor {
private async handleSessionEnd(session: SessionState, watch: WatchTarget): Promise<void> {
await this.queueSummary(session);
await sessionCompleteHandler.execute({
sessionId: session.sessionId,
cwd: session.cwd ?? process.cwd(),
platform: session.platformSource
});
await this.updateContext(session, watch);
session.pendingTools?.clear();
const key = this.getSessionKey(watch, session.sessionId);
+3 -6
View File
@@ -806,11 +806,8 @@ export class WorkerService implements WorkerRef {
} else {
// Successful completion with no pending work — finalize then drop
// in-memory state. finalizeSession flips sdk_sessions.status to
// 'completed', drains orphaned pendings, broadcasts; idempotent so
// the later POST /api/sessions/complete from the Stop hook is a
// no-op. Without this, hooks-disabled installs (and any session
// whose Stop hook fails before /api/sessions/complete) leave the
// DB row permanently 'active'.
// 'completed', drains orphaned pendings, broadcasts. This is the
// sole completion path now that the SessionEnd hook shim is gone.
session.restartGuard?.recordSuccess();
session.consecutiveRestarts = 0;
this.completionHandler.finalizeSession(session.sessionDbId);
@@ -1225,7 +1222,7 @@ async function main() {
if (!platform || !event) {
console.error('Usage: claude-mem hook <platform> <event>');
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
console.error('Events: context, session-init, observation, summarize, user-message');
process.exit(1);
}
@@ -430,11 +430,6 @@ export class SessionRoutes extends BaseRouteHandler {
validateBody(SessionRoutes.summarizeByClaudeIdSchema),
this.handleSummarizeByClaudeId.bind(this)
);
app.post(
'/api/sessions/complete',
validateBody(SessionRoutes.completeByClaudeIdSchema),
this.handleCompleteByClaudeId.bind(this)
);
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
}
@@ -490,11 +485,6 @@ export class SessionRoutes extends BaseRouteHandler {
platformSource: z.string().optional(),
}).passthrough();
private static readonly completeByClaudeIdSchema = z.object({
contentSessionId: z.string().min(1),
platformSource: z.string().optional(),
}).passthrough();
/**
* Initialize a new session
*/
@@ -793,52 +783,6 @@ export class SessionRoutes extends BaseRouteHandler {
});
});
/**
* 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;
const platformSource = normalizePlatformSource(req.body.platformSource);
logger.info('HTTP', '→ POST /api/sessions/complete', { 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, '', '', undefined, platformSource);
// 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)
// Still proceed with DB-backed completion so the row gets marked completed
logger.debug('SESSION', 'session-complete: Session not in active map; continuing with DB-backed completion', {
contentSessionId,
sessionDbId
});
}
// Complete the session (removes from active sessions map if present)
// 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', {
contentSessionId,
sessionDbId
});
res.json({ status: activeSession ? 'completed' : 'completed_db_only', sessionDbId });
});
/**
* Initialize session by contentSessionId (new-hook uses this)
* POST /api/sessions/init
@@ -25,15 +25,14 @@ export class SessionCompletionHandler {
* Finalize a session's persistent + broadcast state.
*
* Idempotent — safe to call twice. The worker calls this from the SDK-agent
* generator's finally-block (primary path), and the HTTP route
* POST /api/sessions/complete also calls it as a backward-compat shim.
* If the session is already marked completed in the DB, this is a no-op.
* generator's finally-block when work naturally completes. If the session
* is already marked completed in the DB, this is a no-op.
*
* This method intentionally does NOT touch the in-memory SessionManager map.
* The generator's finally-block handles in-memory removal via
* `removeSessionImmediate` (which cannot `await` the generator it's running
* inside); the HTTP route layers `deleteSession` on top for the case where
* the generator is still running and needs to be aborted.
* inside); explicit-delete callers layer `deleteSession` on top for the case
* where the generator is still running and needs to be aborted.
*/
finalizeSession(sessionDbId: number): void {
const sessionStore = this.dbManager.getSessionStore();
@@ -76,13 +75,11 @@ export class SessionCompletionHandler {
}
/**
* Complete session by database ID
* Used by DELETE /api/sessions/:id and POST /api/sessions/:id/complete
* Complete session by database ID. Used by explicit user-initiated deletion
* via DELETE /api/sessions/:id and POST /api/sessions/:id/complete (viewer UI).
*
* Calls `finalizeSession` (DB mark + drain + broadcast, idempotent) and then
* aborts any running SDK agent via `sessionManager.deleteSession`. The
* HTTP route wraps this so older callers that still POST to
* /api/sessions/complete keep working even after the worker self-cleans.
* aborts any running SDK agent via `sessionManager.deleteSession`.
*/
async completeByDbId(sessionDbId: number): Promise<void> {
// Finalize first so the DB and broadcast state are consistent even if