fix(claude-code): detach SessionEnd work so CLI shutdown cannot cancel the final retain - #2869
fix(claude-code): detach SessionEnd work so CLI shutdown cannot cancel the final retain#2869merlinr68 wants to merge 1 commit into
Conversation
…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
…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.
|
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 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 ( 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.
|
Every session shorter than
retainEveryNTurns(default 10) loses its memories entirely. Claude Code cancels theSessionEndhook 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
SessionEndhooks 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: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
retainEveryNTurnsturns (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.pymeasures ~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 fromtimed out). Known upstream behavior: anthropics/claude-code#32712, anthropics/claude-code#41577.Reproduction matrix (Claude Code v2.1.216, tmux-driven interactive sessions):
/exitHook cancelled, retain lostFix
The hook entry now does no real work. It re-execs
session_end.pyas a detached child —start_new_session=True,hook_inputpassed via argv, guarded byHINDSIGHT_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 inprestart_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
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.https://claude.ai/code/session_01MTPDCvguQvm5xLKeWuCYY4