From 4c355bc138d1353ec53406e37c070b222094efbb Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 28 May 2026 10:00:15 -0400 Subject: [PATCH] fix(session-start): time-based dedup as defense in depth (v6.1.5) The per-session_id:source_kind dedup added in v6.1.2 only catches the startup-class re-fire case. Codex was observed firing SessionStart 9+ times in rapid succession near session limits (after a user "resume and wrap up" instruction) with rotating session_ids and source values that our existing dedup didn't anticipate. The user's transcript showed 9 identical short-pointer payloads stacked back-to-back. Added a 5-second time-window dedup that runs before the session-id dedup. If any SessionStart payload was emitted in the last 5 seconds, the new fire short-circuits silently regardless of payload shape -- session_id, source, agent_id, or absence of all three. Window is intentionally short (5s) so legitimate user-driven compacts spaced minutes apart still emit their resumption context. The observed bug was 9 fires inside ~1 second; even 1-second window would catch it. Implementation: - Add LAST_EMIT_FILE in STATE_DIR. - Before any state-mutating work, check file mtime; if within window, exit 0. - Touch LAST_EMIT_FILE after emit_additional_context. - mtime check uses both BSD (stat -f %m) and GNU (stat -c %Y) forms. Tests: - Added test_session_start_time_dedup_suppresses_rapid_refires that fires 4 SessionStart events in rapid succession with rotating session_ids and a mix of startup/compact sources, asserts exactly one emits. - Fixed test_session_start_json and test_wrapper_suppresses_unavailable_c_utf8_locale_noise to use isolated tmpdir cwds so the per-cwd state dir doesn't poison subsequent SessionStart tests via the new time dedup. Version bump 6.1.4 -> 6.1.5 across all four manifests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- RELEASE-NOTES.md | 9 ++++++ hooks/session-start | 31 ++++++++++++++++++ tests/hook-contracts.sh | 57 ++++++++++++++++++++++++++++++--- 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d170f15..661e34a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents", - "version": "6.1.4", + "version": "6.1.5", "source": "./", "author": { "name": "Jon Langevin", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 08802bc..5aa00dc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "autodev", "description": "Autonomous development workflow skills for coding agents: design, review, planning, execution, monitoring, and retrospectives", - "version": "6.1.4", + "version": "6.1.5", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 093bd1d..9aa7827 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "autodev", "displayName": "Autonomous Dev Kit", "description": "Autonomous development workflow skills for coding agents", - "version": "6.1.4", + "version": "6.1.5", "author": { "name": "Jon Langevin", "email": "jon@gocodealone.com" diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3d6d915..47c23c1 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,14 @@ # Autonomous Dev Kit Release Notes +## v6.1.5 — 2026-05-28 + +SessionStart time-based dedup as defense in depth. + +- `hooks/session-start`: added a 5-second time-window dedup that suppresses ANY rapid re-fire regardless of payload shape. The per-`session_id:source_kind` dedup added in v6.1.2 only covers startup-class fires where the host populates `session_id` consistently. Codex was observed firing SessionStart 9+ times in rapid succession near session limits — possibly during internal resume/wrap-up lifecycle events — with rotating `session_id` and source values our existing dedup didn't anticipate. Time dedup catches that uniformly: if any SessionStart payload was emitted in the last 5 seconds, the new fire short-circuits silently. +- The 5-second window is intentionally short so legitimate user-driven compacts spaced minutes apart still emit their resumption context. The observed bug was 9 fires inside ~1 second. +- Added regression test asserting four rapid fires with rotating `session_id` + source produce exactly one emission. +- Test isolation: `tests/hook-contracts.sh` SessionStart tests now use isolated tmpdir cwds so the per-cwd dedup state doesn't leak across tests. + ## v6.1.4 — 2026-05-28 PreToolUse guard quote-strip extended to all destructive-command checks. diff --git a/hooks/session-start b/hooks/session-start index 87b2995..306e367 100755 --- a/hooks/session-start +++ b/hooks/session-start @@ -59,6 +59,32 @@ STATE_DIR="${cwd_dir}/.claude/autodev-state" STATE_FILE="${STATE_DIR}/in-progress.jsonl" SEEN_FILE="${STATE_DIR}/session-start-seen" SESSION_ID_FILE="${STATE_DIR}/current-session-id" +LAST_EMIT_FILE="${STATE_DIR}/session-start-last-emit" + +# Time-based dedup (defense in depth, applies to ALL source kinds). +# +# The per-session_id:source_kind dedup below handles startup-class re-fires +# where Codex / the host populates session_id consistently. But Codex has +# also been observed firing SessionStart 9+ times in rapid succession near +# session limits (and on resume/wrap-up lifecycle events), with payload +# fields we don't always see -- session_id may rotate, source may be a +# value we don't anticipate, agent_id may be absent on subagent-style +# fires. Time dedup catches all of those regardless of payload shape: +# if a SessionStart payload was emitted in the last N seconds, skip. +# +# Window is intentionally short (5s) so legitimate user-driven compacts +# spaced minutes apart still emit their resumption context. The 9-in- +# rapid-succession bug happens within ~1 second. +DEDUP_WINDOW_SEC=5 +if [ -f "$LAST_EMIT_FILE" ]; then + now=$(date +%s 2>/dev/null || echo 0) + last=$(stat -f %m "$LAST_EMIT_FILE" 2>/dev/null \ + || stat -c %Y "$LAST_EMIT_FILE" 2>/dev/null \ + || echo 0) + if [ "$now" -gt 0 ] && [ "$last" -gt 0 ] && [ $((now - last)) -lt "$DEDUP_WINDOW_SEC" ]; then + exit 0 + fi +fi # Detect session transitions via session_id rather than source=startup. # Codex has been observed re-firing SessionStart 6+ times on a single @@ -207,4 +233,9 @@ PY emit_additional_context "SessionStart" "$session_context" +# Touch the dedup timestamp file AFTER emit. Subsequent invocations within +# DEDUP_WINDOW_SEC will short-circuit via the time-dedup check at top. +mkdir -p "$STATE_DIR" 2>/dev/null || true +: > "$LAST_EMIT_FILE" 2>/dev/null || true + exit 0 diff --git a/tests/hook-contracts.sh b/tests/hook-contracts.sh index 7baedce..0c1302c 100755 --- a/tests/hook-contracts.sh +++ b/tests/hook-contracts.sh @@ -86,9 +86,53 @@ assert_hook_context_json() { pass "${name}: emits valid ${event} additionalContext JSON" } +test_session_start_time_dedup_suppresses_rapid_refires() { + # Regression for v6.1.5: Codex was observed firing SessionStart 9+ times + # in rapid succession near session limits. Session-id-based dedup misses + # this when session_id rotates or source is a value we don't anticipate. + # Time-based dedup (default 5s window) must catch ALL rapid re-fires + # regardless of payload shape -- different session_id, different source. + local tmp out1 out2 out3 out4 + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + # Fire 1: fresh state, emits. + out1="$(run_hook session-start '{"source":"startup","cwd":"'"$tmp"'","session_id":"sA"}')" + if [ -z "$out1" ]; then + fail "session-start: first fire must emit, got empty" + return + fi + # Fire 2: same session+source within window -> suppressed by session-id dedup OR time dedup. + out2="$(run_hook session-start '{"source":"startup","cwd":"'"$tmp"'","session_id":"sA"}')" + if [ -n "$out2" ]; then + fail "session-start: same-session re-fire within window must be suppressed, got: ${out2}" + return + fi + # Fire 3: rotated session_id, same window -> session-id dedup wouldn't catch this; + # time dedup must. + out3="$(run_hook session-start '{"source":"startup","cwd":"'"$tmp"'","session_id":"sB"}')" + if [ -n "$out3" ]; then + fail "session-start: rotated-session_id re-fire within window must be suppressed (time dedup), got: ${out3}" + return + fi + # Fire 4: different source (compact, normally NOT deduped), same window. + # Time dedup must still suppress to prevent the 9-in-rapid-succession bug. + out4="$(run_hook session-start '{"source":"compact","cwd":"'"$tmp"'","session_id":"sC"}')" + if [ -n "$out4" ]; then + fail "session-start: compact re-fire within window must be suppressed (time dedup), got: ${out4}" + return + fi + pass "session-start: time-based dedup suppresses rapid re-fires across session_id/source rotations" +} + test_session_start_json() { - local output - output="$(run_hook session-start '{"source":"startup","cwd":"'"$REPO_ROOT"'"}')" + # Use isolated tmpdir cwd so the hook's per-cwd state dir + # (.claude/autodev-state) doesn't leak across tests -- the time-based + # dedup added in v6.1.5 would otherwise suppress emissions in tests + # that share the same cwd within the 5-second window. + local tmp output + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + output="$(run_hook session-start '{"source":"startup","cwd":"'"$tmp"'"}')" assert_hook_context_json "session-start" "SessionStart" "$output" } @@ -193,13 +237,17 @@ test_subagent_scope_guard_block_exits_zero_with_stderr_reason() { } test_wrapper_suppresses_unavailable_c_utf8_locale_noise() { - local tmp stdout_file stderr_file stderr_text stdout_text + local tmp stdout_file stderr_file stderr_text stdout_text cwd_dir tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' RETURN stdout_file="$tmp/stdout.json" stderr_file="$tmp/stderr.txt" + # Use isolated cwd so v6.1.5's time-based session-start dedup doesn't + # suppress this emission when other tests just ran in REPO_ROOT. + cwd_dir="$tmp/cwd" + mkdir -p "$cwd_dir" - run_hook_wrapper session-start '{"source":"startup","cwd":"'"$REPO_ROOT"'"}' "$stdout_file" "$stderr_file" + run_hook_wrapper session-start '{"source":"startup","cwd":"'"$cwd_dir"'"}' "$stdout_file" "$stderr_file" stdout_text="$(cat "$stdout_file")" stderr_text="$(cat "$stderr_file")" @@ -1472,6 +1520,7 @@ JSONL require_jq test_session_start_json +test_session_start_time_dedup_suppresses_rapid_refires test_wrapper_suppresses_unavailable_c_utf8_locale_noise test_prompt_strict_json test_prompt_strict_no_output_without_trigger