Skip to content

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

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

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

@merlinr68 merlinr68 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Every session shorter than retainEveryNTurns (default 10) loses its memories entirely. Claude Code cancels the SessionEnd hook at shutdown teardown, so the forced final retain never runs — zero SessionEnd retains landed across 36 hours of real sessions. The fix detaches the work into a child process the CLI cannot cancel.


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
nicoloboschi added a commit that referenced this pull request Aug 12, 2026
…server (#3415)

The write-back cadence is removed. The persistent-plugin harnesses (opencode,
Kilo, Cline CLI) now write back every turn, which is what the default of 1
already did; the hook harnesses never consulted it at all.

Two reasons, and the second is what makes it a removal rather than a
documentation fix.

A client-side cadence holds turns in a process the host can close at any moment,
and there is no reliable signal on the way out: #2869 measured ZERO SessionEnd
retains across 36 hours because Claude Code cancels that hook at shutdown. The
flush a cadence needs cannot be built on the only event that would carry it, so
extending the setting to the seven hook harnesses was never available — and the
asymmetry that left (honoured by three of eleven) was itself the complaint.

The batching it approximated now happens server-side, where nothing can be
stranded: queued retains for one document fold into a single execution
(`engine.retain.fold`, #3395), on top of a claim predicate that serialises
same-document work. Submitting every turn therefore costs one extraction over
the concatenated turns rather than one per turn — the burst control #2143 asked
for, in the place that can do it without risking a turn.

`retainSessions` stays: opting out of write-back entirely is a different
question from how often it fires.

Removed from the config type, the resolver, HINDSIGHT_RETAIN_EVERY_TURNS, the
runtime check and its now-unread retainedUsers bookkeeping, the settings table,
and the tests. The other integrations' `retainEveryNTurns` is a separate setting
in separate packages and is untouched.
@nicoloboschi

Copy link
Copy Markdown
Collaborator

Closing, but this is the most useful thing anyone sent us this week — the measurement outlived the patch.

The bug isn't reachable in Coding Agents, which supersedes the per-agent claude-code plugin: it never registers SessionEnd. Stop fires after every assistant response, so each response has already been written back before any shutdown, and there is no cadence for a short session to fall below. The failure you found — "every session shorter than retainEveryNTurns loses its memories entirely" — needs both a cadence and a session-end flush, and there is now neither.

Your evidence is why there is neither. "Zero SessionEnd retains across 36 hours, because Claude Code cancels the hook at shutdown" is a hard fact about the host, and it settled a design question that was open on our side. We had documented a cadence setting that only three of eleven harnesses honoured, and the obvious next step was to extend it to the hook harnesses — which needs a flush at session end, which would have been built on exactly the event you measured being cancelled. It would have silently stranded the tail of every short session.

Instead we removed the cadence entirely (#3415) and let the server do the batching, where nothing can be stranded by a client closing: queued retains for one document fold into a single execution (engine.retain.fold, #3395). Every turn is submitted immediately and durably; the coalescing happens somewhere that cannot be killed mid-window.

For completeness, the analogue we do carry: our Stop hook waits on the daemon before retaining, so a hook killed in that window loses that response's write-back. Mid-session that is harmless — the cursor is untouched and the next Stop appends everything since — and it only bites on a session's final response with a cold daemon, which is a session's beginning rather than its end. If anyone reports a missing last exchange, your detach-into-a-child-process technique is the remedy on file.

npx @vectorize-io/hindsight-coding-agents install claude-code (docs). Thank you for measuring it over 36 hours rather than reasoning about it — that is what made it actionable.

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.

2 participants