Skip to content

Poll transcript on Stop hook to handle pure-text flush race#153

Merged
ikuwow merged 2 commits into
mainfrom
stop-hook-poll-retry
May 1, 2026
Merged

Poll transcript on Stop hook to handle pure-text flush race#153
ikuwow merged 2 commits into
mainfrom
stop-hook-poll-retry

Conversation

@ikuwow

@ikuwow ikuwow commented May 1, 2026

Copy link
Copy Markdown
Owner

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, so collect_current_turn_assistant_text reads 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) and collect_current_turn_assistant_text(events) are unchanged. Only main() gains the retry loop and two module-level constants POLL_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:

sample iter where text appeared elapsed_ms
1 1 54
2 2 118
3 2 114
4 2 112
5 2 115

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

  • doctest: python3 -m doctest claude/hooks/block_excuse_phrases.py -v reports 16 tests in 4 items. 16 passed.
  • Forbidden text smoke (saved transcript): hook outputs decision:block JSON and exits 0.
  • Empty file (timeout case): hook prints block_excuse_phrases: poll timeout after 535ms events=0 transcript=/tmp/test_empty.jsonl to 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=true payload: silent exit 0, no polling.
  • Missing transcript_path: stderr breadcrumb, exit 0.
  • Non-existent file: stderr breadcrumb, exit 0 (initial read failure does not enter the poll loop).
  • pre-commit on the changed file: trailing whitespace / EOF / large file checks all pass.
  • Live runtime confirmation: while the branch was checked out (deployed via symlink from 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

  • Reporting the underlying flush-timing behavior to Anthropic (anthropics/claude-code) is recommended as a follow-up but kept separate from this code change.
  • Reducing the verbose user-facing "Stop hook feedback" notification surfaced on every block is tracked separately in Reduce Stop hook block notification user-facing noise #154.

ikuwow and others added 2 commits May 1, 2026 17:25
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>
@ikuwow ikuwow marked this pull request as ready for review May 1, 2026 08:34
@ikuwow ikuwow merged commit adbaf22 into main May 1, 2026
6 checks passed
@ikuwow ikuwow deleted the stop-hook-poll-retry branch May 1, 2026 08:57
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