Skip to content

fix(hook): complete the SessionEnd run after the CLI shutdown - #63

Draft
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:fix/issue-13-sessionend-fast-path
Draft

fix(hook): complete the SessionEnd run after the CLI shutdown#63
milanagm wants to merge 2 commits into
langfuse:mainfrom
milanagm:fix/issue-13-sessionend-fast-path

Conversation

@milanagm

@milanagm milanagm commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

This PR fixes #13.
Linear: LFE-10758.

Problem

hooks/langfuse_hook.py imported langfuse and opentelemetry at module load. The module started the import before it knew the plugin configuration, and before it knew the number of new bytes in the
transcript. Thus every hook run did the slow import.

The issue has two causes, and thus this PR makes two changes, one commit for each:

  • fix(hook): import the SDK only when a run has work to emit
  • feat(hook): finish the SessionEnd upload in a child process

The second commit stays separate on purpose. A reviewer can read the fork on its own, and a
revert of that one commit removes it.

Fix

The fast checks run first. The SDK import starts only when the run has work to do.

  • from __future__ import annotations makes all annotations strings. Thus an annotation that
    names Langfuse does not start the import at definition time.
  • The langfuse and opentelemetry imports moved into _ensure_langfuse_imported(), and
    create_langfuse_client() calls that function. The optional langfuse.media import moved
    there too, because that import also loads the langfuse package.
  • The fail-open behavior stays the same. After a failure the hook writes the known diagnostic for
    PATH and uv to the log. The function returns None, and the hook exits with code 0.
  • A new function _has_pending_work(session_id, transcript_path) runs first in main(). It reads
    the entry in the state file and the size of the transcript file. It takes no lock and it starts
    no import. The run has work to do when the transcript has bytes after the saved offset. The run
    also has work to do when the entry contains deferred agent turns, stashed task notifications,
    or an open turn.
  • The check fails open. The run uses the normal path after an exception in the check. The run also
    uses the normal path for an unknown session.

Second change: a child process completes the SessionEnd run

_detach_from_cli() makes one fork, and only for a SessionEnd run with work to do. The parent
process exits immediately, and thus the CLI continues immediately. The child process leaves the
process group with setsid, redirects the three standard descriptors to /dev/null, and then
completes the upload. The redirect is necessary, because the CLI closes those pipes. The log file
is the only output channel of the child process.

Important details:

  • The fork happens after the fast check and before create_langfuse_client. Thus the process has
    no SDK thread yet. A fork is not safe when the process has an exporter thread.
  • A Stop run stays in the foreground. It has no shutdown deadline, the CLI waits for it, and a run
    in the foreground shows its errors.
  • Windows has no os.fork. When os.fork is absent, or when the fork fails, the run stays in the
    foreground and writes the reason to the log. The behavior is then the same as the behavior
    before this PR.
  • CC_LANGFUSE_SYNC_SESSION_END=true keeps the SessionEnd run in the foreground. A user who looks
    for a missing last turn can set it. The CLI then waits for the upload and shows the failure. The
    README documents the option and has a row for it in the troubleshooting table.
  • The test suite sets this option in the autouse fixture. Without the option a test that sends a
    SessionEnd payload forks the pytest process, and the rest of the suite runs two times.

Tests

The suite has 205 tests, and 19 of them are new. All of them pass. The first commit alone passes
197 tests, so both states in this PR are green.

  • tests/unit/test_pending_work_check.py tests _has_pending_work for these inputs: new bytes in
    the transcript, deferred agent turns, stashed task notifications, and an open turn. It also tests
    an old timestamp, an entry with an empty timestamp field, and an unknown session. Two more tests
    go through main().
  • tests/unit/test_lazy_import_skips_when_no_work.py loads a new copy of the module and does not
    start the import. It then tests that main() leaves Langfuse unset for three cases: a plugin
    without keys, a missing transcript, and a SessionEnd run with no work. A fourth test shows that a
    run with work starts the import and builds a client.
  • tests/unit/test_import_failure.py tests the contract of the old guard at module level. When the
    SDK is not available and the session has work, main() returns 0 and the diagnostic reaches the
    log.
  • tests/unit/test_session_end_detach.py tests the fork for four cases. Two cases are the branch of
    the parent process and the branch of the child process with setsid and the three redirects. The
    other two cases are a fork that fails and the CC_LANGFUSE_SYNC_SESSION_END option.
  • Four more tests in the same file go through main(). A SessionEnd parent process returns before
    it builds a client, and the child process does the work. A Stop run stays in the foreground, and
    it makes no fork. A run with no work to do returns before the fork.
  • Mutation tests on a copy of the tree: we removed the import guard, then the open_turn condition,
    then the staleness condition. The suite fails for each of the three changes.
  • Four more mutations of the fork also fail the suite. Those four are a fork for a Stop run, no
    fork at all, no setsid, and a parent that continues instead of a return.
  • tests/unit/test_image_capture.py from feat(hook): capture image blocks as markers and opt-in Langfuse media #56 still passes with the media import now lazy.

An end-to-end test against a local Langfuse with a real nested Claude Code session shows three
results. The normal path stays the same. The Stop run marks the turn as an open turn, the SessionEnd
run completes it, and the trace has the output field. A second SessionEnd run skips the import. An
entry with a timestamp of 8 days uses the normal path and gets a new timestamp.

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.

SessionEnd hook is killed during CLI shutdown — "Hook cancelled" shown on every exit

1 participant