Skip to content

fix(claude-code): detach SessionEnd work so CLI shutdown cannot cancel the final retain - #2869

Open
merlinr68 wants to merge 1 commit into
vectorize-io:mainfrom
merlinr68:fix/claude-code-sessionend-detach
Open

fix(claude-code): detach SessionEnd work so CLI shutdown cannot cancel the final retain#2869
merlinr68 wants to merge 1 commit into
vectorize-io:mainfrom
merlinr68:fix/claude-code-sessionend-detach

Conversation

@merlinr68

Copy link
Copy Markdown
Contributor

Problem

Claude Code cancels SessionEnd hooks that are still in flight when its shutdown teardown finishes. With this plugin installed, every shutdown of a session in an MCP-heavy project prints:

SessionEnd hook [python3 "${CLAUDE_PLUGIN_ROOT}/scripts/session_end.py" || python "${CLAUDE_PLUGIN_ROOT}/scripts/session_end.py"] failed: Hook cancelled

The visible error is the small part. The silent part is data loss: the cancelled hook never delivers the forced final retain, so any session shorter than retainEveryNTurns turns (default 10) loses its memories entirely, and longer sessions lose the tail after the last Stop-hook retain. Checking my self-hosted server logs: zero SessionEnd retains arrived over 36 hours of real sessions — the only retains that landed were mid-session Stop-hook ones.

Root cause

This is not a timeout and not a plugin bug per se — session_end.py measures ~0.2s end-to-end against a 1.4MB transcript, far under its 10s hook budget. Claude Code kills the hook at the session-exit signal level (Hook cancelled, as distinct from timed out). Known upstream behavior: anthropics/claude-code#32712, anthropics/claude-code#41577.

Reproduction matrix (Claude Code v2.1.216, tmux-driven interactive sessions):

Exit path Bare directory MCP-heavy project
/exit clean clean
Ctrl+C Ctrl+C clean Hook cancelled, retain lost

Fix

The hook entry now does no real work. It re-execs session_end.py as a detached child — start_new_session=True, hook_input passed via argv, guarded by HINDSIGHT_SESSION_END_DETACHED — and returns in ~85ms, leaving the CLI's cancellation window nothing to kill. The child runs in its own session, survives the CLI's process-group teardown, and performs the forced final retain + daemon stop exactly as before. This is the same detach pattern the plugin already uses in prestart_daemon_background(), and the workaround recommended in anthropics/claude-code#41577.

Trade-off: the child's stderr is discarded (it can outlive the terminal), so final-retain errors are no longer visible at shutdown. Given the alternative is the retain being silently killed, this seems like the right side of the trade.

Testing

  • 3 new tests in tests/test_hooks.py (TestSessionEndHook): hook entry spawns exactly one detached child with the marker env + hook_input argv and does no inline network work; detached child performs the final retain from argv; detached child skips retain when there is no transcript.
  • Full integration suite: 209 passed.
  • Live verification on v2.1.216: the previously-failing scenario (MCP-heavy project, real turn, Ctrl+C Ctrl+C) now exits with no hook error and the final retain arrives at the Hindsight server (confirmed in server logs).

https://claude.ai/code/session_01MTPDCvguQvm5xLKeWuCYY4

…l the final retain

Claude Code cancels SessionEnd hooks that are still in flight when its
shutdown teardown finishes. This is reliably reproducible by exiting with
Ctrl+C Ctrl+C in a project with many MCP servers (the same exit is clean in
a bare directory, and /exit is always clean):

    SessionEnd hook [python3 ".../session_end.py" || ...] failed: Hook cancelled

See anthropics/claude-code#32712 and anthropics/claude-code#41577.

The hook itself is fast (~0.2s measured end-to-end against a 1.4MB
transcript), so this is not a timeout: the hook is killed at the
session-exit signal level. The damage is silent data loss — the forced
final retain never reaches the server, so any session shorter than
retainEveryNTurns turns loses its memories entirely (verified server-side:
zero SessionEnd retains arrived over 36h of real sessions).

Fix: the hook entry now does no real work. It re-execs session_end.py as a
detached child (start_new_session=True, hook_input passed via argv, guarded
by HINDSIGHT_SESSION_END_DETACHED) and returns in ~85ms. The child runs in
its own session, survives the CLI's process-group teardown, and performs
the forced final retain + daemon stop exactly as before.

Verified on Claude Code v2.1.216: the previously-failing scenario
(MCP-heavy project, real turn, Ctrl+C Ctrl+C) now exits with no hook error
AND the final retain arrives at the Hindsight server.

Claude-Session: https://claude.ai/code/session_01MTPDCvguQvm5xLKeWuCYY4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant