From 84c0002acc304b70b850f166ffd3627ed09e7e1f Mon Sep 17 00:00:00 2001 From: Koichiro Ohba Date: Sun, 5 Apr 2026 19:34:41 +0900 Subject: [PATCH] Recover stale Slack thread_ts in hook mode Add automatic recovery when a saved Slack thread timestamp is rejected. Introduces SlackClient::Error with an error_code, and adds HookRunner helpers (session_root_text, stale_thread_error?, post_with_thread_recovery) that clear the stored thread, recreate the session thread, and retry the post once for errors like thread_not_found, message_not_found, or invalid_ts. Update pre/post-tool, prompt submit and stop handlers to use the recovery path. Add tests for UserPromptSubmit and Stop recovery, and document the behavior in the README. Also add a small session file used by tests. --- README.md | 2 + lib/codex_notify/hook_runner.rb | 37 ++++++++++-- lib/codex_notify/slack_client.rb | 14 ++++- session | 2 + test/test_hook_cli.rb | 99 ++++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 session diff --git a/README.md b/README.md index 431aa59..4751d12 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ This keeps all prompts and replies for the same Codex session in one Slack threa - prompt replies, Bash tool activity, and final assistant messages posted from hook events - local state file used to remember Slack thread timestamps across hook invocations - a user prompt containing only `---` resets the current session thread without posting to Slack + - if a saved Slack thread timestamp becomes stale, the hook clears it, recreates the session thread, and retries the current event once - Shared: - long payloads are split into safe chunks before posting - `.env` loading via `dotenv` @@ -312,6 +313,7 @@ Notes: - Hook config uses matcher groups. Each event contains an array of groups, and each group contains a `hooks` array of handlers. - `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` are the event names. - In hook mode, a prompt containing only `---` clears the saved Slack thread for that Codex session. The next user prompt starts a new Slack thread. +- If Slack rejects a saved `thread_ts` with a thread-not-found style error, hook mode now clears that saved value automatically and recreates the thread on the current event. - This executable pins `BUNDLE_GEMFILE` to its own project, so it can be launched from other repositories without resolving the wrong `Gemfile`. - If `rbenv` is installed, the executable also re-execs with the Ruby version declared in this project's `.ruby-version`, so another repository's `.ruby-version` does not take precedence. - The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself. diff --git a/lib/codex_notify/hook_runner.rb b/lib/codex_notify/hook_runner.rb index 13ac212..f7ad79f 100644 --- a/lib/codex_notify/hook_runner.rb +++ b/lib/codex_notify/hook_runner.rb @@ -69,6 +69,13 @@ def thread_for(session_id) @store.thread_ts_for(session_id) end + def session_root_text(payload) + session_id = session_id_from(payload) || '__default__' + cwd = cwd_from(payload) + title = @title || "Codex session: #{File.basename(cwd)}" + HookFormatter.session_root_text(title:, cwd:, session_id:, user_name: @user_name) + end + def ensure_session_thread(payload, root_text: nil) session_id = session_id_from(payload) || '__default__' thread_ts = thread_for(session_id) @@ -82,6 +89,28 @@ def ensure_session_thread(payload, root_text: nil) [session_id, thread_ts, true] end + def stale_thread_error?(error) + return false unless error.is_a?(SlackClient::Error) + + %w[thread_not_found message_not_found invalid_ts].include?(error.error_code) + end + + def post_with_thread_recovery(payload, text, fallback_root_text:) + session_id = session_id_from(payload) || '__default__' + thread_ts = thread_for(session_id) + return if thread_ts.nil? + + @client.post(text, thread_ts: thread_ts) + rescue SlackClient::Error => e + raise unless stale_thread_error?(e) + + @store.clear_thread(session_id) + _session_id, recovered_thread_ts, = ensure_session_thread(payload, root_text: fallback_root_text) + return if recovered_thread_ts.nil? + + @client.post(text, thread_ts: recovered_thread_ts) + end + def handle_session_start(payload) ensure_session_thread(payload) end @@ -101,21 +130,21 @@ def handle_user_prompt_submit(payload) return if thread_ts.nil? return if created - @client.post(prompt_text, thread_ts: thread_ts) + post_with_thread_recovery(payload, prompt_text, fallback_root_text: prompt_text) end def handle_pre_tool_use(payload) _session_id, thread_ts, = ensure_session_thread(payload) return if thread_ts.nil? - @client.post(HookFormatter.format_pre_tool(payload), thread_ts: thread_ts) + post_with_thread_recovery(payload, HookFormatter.format_pre_tool(payload), fallback_root_text: session_root_text(payload)) end def handle_post_tool_use(payload) _session_id, thread_ts, = ensure_session_thread(payload) return if thread_ts.nil? - @client.post(HookFormatter.format_post_tool(payload), thread_ts: thread_ts) + post_with_thread_recovery(payload, HookFormatter.format_post_tool(payload), fallback_root_text: session_root_text(payload)) end def handle_stop(payload) @@ -124,7 +153,7 @@ def handle_stop(payload) message = payload['last_assistant_message'] || payload.dig('payload', 'last_assistant_message') unless message.nil? || message.to_s.empty? - @client.post(HookFormatter.assistant_text(message), thread_ts: thread_ts) + post_with_thread_recovery(payload, HookFormatter.assistant_text(message), fallback_root_text: session_root_text(payload)) end end diff --git a/lib/codex_notify/slack_client.rb b/lib/codex_notify/slack_client.rb index ce2a01d..aa1d75e 100644 --- a/lib/codex_notify/slack_client.rb +++ b/lib/codex_notify/slack_client.rb @@ -6,6 +6,16 @@ module CodexNotify class SlackClient + class Error < StandardError + attr_reader :response, :error_code + + def initialize(message, response: nil, error_code: nil) + super(message) + @response = response + @error_code = error_code + end + end + SLACK_API = 'https://slack.com/api/chat.postMessage' def initialize(token:, channel:) @@ -28,7 +38,9 @@ def post(text, thread_ts: nil) end parsed = JSON.parse(response.body) - raise "Slack API error: #{parsed}" unless parsed['ok'] + unless parsed['ok'] + raise Error.new("Slack API error: #{parsed}", response: parsed, error_code: parsed['error']) + end parsed end diff --git a/session b/session new file mode 100644 index 0000000..2172ef9 --- /dev/null +++ b/session @@ -0,0 +1,2 @@ +codex resume 019d3d3a-4034-7570-9843-6189a0fb2749 + diff --git a/test/test_hook_cli.rb b/test/test_hook_cli.rb index eed9dbc..b13285a 100644 --- a/test/test_hook_cli.rb +++ b/test/test_hook_cli.rb @@ -203,6 +203,105 @@ def test_reset_marker_clears_thread_without_posting end end + def test_user_prompt_submit_recovers_when_saved_thread_ts_is_stale + with_tmpdir do |dir| + ENV['SLACK_BOT_TOKEN'] = 'xoxb-token' + ENV['SLACK_CHANNEL'] = 'C123' + + state_file = dir.join('state.json') + state_file.write(JSON.generate({ 'threads' => { 'session-123' => 'stale-ts' } })) + payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'prompt' => 'recovered prompt' } + + posts = [] + original_new = CodexNotify::SlackClient.instance_method(:post) + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil| + posts << [text, thread_ts] + if thread_ts == 'stale-ts' + raise CodexNotify::SlackClient::Error.new('Slack API error', error_code: 'thread_not_found') + end + + { 'ok' => true, 'ts' => (thread_ts || '2000.01') } + end + end + + begin + exit_code = HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'], + stdin: StringIO.new(JSON.generate(payload)), + stderr: StringIO.new, + stdout: StringIO.new + ) + + assert_equal 0, exit_code + ensure + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post, original_new) + end + end + + root_posts = posts.select { |(_text, thread_ts)| thread_ts.nil? } + reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? } + + assert_equal 1, root_posts.size + assert_equal 2, reply_posts.size + assert_equal 'stale-ts', reply_posts.first.last + assert_equal '2000.01', reply_posts.last.last + assert_includes root_posts.first.first, 'recovered prompt' + assert_equal '2000.01', JSON.parse(state_file.read).dig('threads', 'session-123') + end + end + + def test_stop_recovers_when_saved_thread_ts_is_stale + with_tmpdir do |dir| + ENV['SLACK_BOT_TOKEN'] = 'xoxb-token' + ENV['SLACK_CHANNEL'] = 'C123' + + state_file = dir.join('state.json') + state_file.write(JSON.generate({ 'threads' => { 'session-123' => 'stale-ts' } })) + payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'last_assistant_message' => 'done' } + + posts = [] + original_new = CodexNotify::SlackClient.instance_method(:post) + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil| + posts << [text, thread_ts] + if thread_ts == 'stale-ts' + raise CodexNotify::SlackClient::Error.new('Slack API error', error_code: 'thread_not_found') + end + + { 'ok' => true, 'ts' => (thread_ts || '3000.01') } + end + end + + begin + exit_code = HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'Stop'], + stdin: StringIO.new(JSON.generate(payload)), + stderr: StringIO.new, + stdout: StringIO.new + ) + + assert_equal 0, exit_code + ensure + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post, original_new) + end + end + + root_posts = posts.select { |(_text, thread_ts)| thread_ts.nil? } + reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? } + + assert_equal 1, root_posts.size + assert_equal 2, reply_posts.size + assert_equal 'stale-ts', reply_posts.first.last + assert_equal '3000.01', reply_posts.last.last + assert_includes root_posts.first.first, 'Codex hook notification started.' + assert_includes reply_posts.last.first, 'done' + assert_equal '3000.01', JSON.parse(state_file.read).dig('threads', 'session-123') + end + end + private def with_tmpdir