Poll transcript on Stop hook to handle pure-text flush race#153
Merged
Conversation
Pure-text turns (no tool calls) hit a Claude Code race where the assistant event is not yet flushed to the transcript JSONL when the Stop hook fires. The 断り書き hook from PR 152 sees an empty current- turn text and lets the response through. Tool-using turns work because tool execution forces I/O sync points. When the initial read returns empty text, poll the transcript at 50ms intervals up to 500ms (10 iterations) before giving up fail-open. An empirical 5-sample distribution in this repo clustered between 54-118ms, so 500ms gives ~4x headroom. Timeout emits a stderr breadcrumb in line with the existing fail-open pattern. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Three fixes from PR review: - Replace POLL_MAX_TOTAL_S with POLL_MAX_ITERATIONS (10) so the loop bound is integer-defined. Avoids latent FP-truncation footguns if someone later picks constants where division does not round cleanly. - Clarify docstring: poll-iteration read failures are retried silently (no per-iteration breadcrumb); only terminal fall-throughs emit one. - Enrich timeout breadcrumb with event count, transcript path, and the last poll-iteration exception so an operator can distinguish flush lag from a permanently broken transcript. Trim "5-sample / 54-118ms" specifics from the docstring so the source tracks rationale, not one-off measurement noise; PR 153 carries the data table. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
PR #152 (#152) added the 断り書き Stop hook (
block_excuse_phrases.py), but the regex match misses pure-text turns. Investigation in this session showed Claude Code can invoke the Stop hook before the assistant event is flushed to the transcript JSONL, socollect_current_turn_assistant_textreads stale data and returns an empty string. Tool-using turns are unaffected because tool execution forces an I/O sync point.This PR closes the gap with a bounded poll-retry inside the hook.
Approach
After the initial transcript read, if the current-turn assistant text is empty, the hook polls the transcript at 50ms intervals up to 500ms (10 iterations) before giving up fail-open. As soon as text appears, the regex check runs as before. On timeout, the hook emits a stderr breadcrumb (
block_excuse_phrases: poll timeout after Nms) and exits 0, matching the existing fail-open pattern for the other I/O failure paths.The polling does not change any pure function in the module —
is_forbidden(text)andcollect_current_turn_assistant_text(events)are unchanged. Onlymain()gains the retry loop and two module-level constantsPOLL_INTERVAL_S/POLL_MAX_ITERATIONS.Empirical data
The thresholds come from an in-session experiment that instrumented the same hook with polling and detailed logging. Across five pure-text turns in one Claude Code session:
All five turns flushed within 120ms, with zero timeouts. 500ms gives roughly 4x headroom over the maximum observed value, which keeps response latency unaffected in practice while staying robust to slower disks or scheduler jitter. The samples also strongly suggest that Claude Code does not wait for the Stop hook to return before flushing — flushing appears to happen in parallel with hook execution.
Verification
python3 -m doctest claude/hooks/block_excuse_phrases.py -vreports16 tests in 4 items. 16 passed.decision:blockJSON and exits 0.block_excuse_phrases: poll timeout after 535ms events=0 transcript=/tmp/test_empty.jsonlto stderr and exits 0 with no JSON output. Actual elapsed slightly exceeds 500ms because of OS sleep granularity (time.sleep(0.05)returns marginally late); within tolerance.stop_hook_active=truepayload: silent exit 0, no polling.claude/hooks/), pure-text replies that included 「正直」 or 「実は」 as 断り書き preambles were caught by the hook on the first stop, with the correction applied via the standard block-and-rewrite flow. This reproduced the original bug condition (pure-text turn, no tool calls) and demonstrated the fix end to end before merge.Out of scope
anthropics/claude-code) is recommended as a follow-up but kept separate from this code change.