Skip to content

fix: add catch-all block_actions handler to prevent unhandled 404s - #1

Open
cresclaber wants to merge 38 commits into
mainfrom
matt/fix-block-actions-unhandled
Open

fix: add catch-all block_actions handler to prevent unhandled 404s#1
cresclaber wants to merge 38 commits into
mainfrom
matt/fix-block-actions-unhandled

Conversation

@cresclaber

Copy link
Copy Markdown

Summary

  • Registers a catch-all @app.action(re.compile(".*")) handler in _register_handlers()
  • Immediately calls ack() to prevent Slack's 3-second timeout error and the "Unsuccessful Bolt execution result (status: 404)" log spam
  • Routes the button's value/text back into _process_message() as a synthetic event so the agent can continue the conversation in context

Root cause

Observed in production logs (2026-06-09):

[WARNING] Unhandled request ({'type': 'block_actions', 'block_id': 'Qd4ey', 'action_id': 'OMPQL'})
[WARNING] Unhandled request ({'type': 'block_actions', 'block_id': 'SR5N0', 'action_id': '0r41V'})

When the agent posts a Block Kit message with interactive buttons and a user clicks one, Slack sends a block_actions event. Without a registered handler, Slack Bolt returns 404 and the user sees an operation-failed error in Slack UI.

Test plan

  • Unit test: verify handle_block_action acks and does NOT call _process_message when channel/thread_ts/user_id are missing
  • Unit test: verify handle_block_action routes action.value to _process_message as a synthetic event
  • Manual: post a Block Kit message with a button, click it, confirm no 404 warning in logs and agent responds in thread

🤖 Generated with Claude Code

Matt and others added 30 commits April 16, 2026 08:47
New env vars:
- AGENT_BRIDGE_SLACK_STARTUP_NOTIFY_CHANNEL
- AGENT_BRIDGE_SLACK_STARTUP_NOTIFY_MESSAGE

When both are set, the Slack adapter sends a message to the
specified channel immediately after Socket Mode connects.
This lets deploy pipelines confirm the bot is actually alive.
feat: add optional startup notification after Socket Mode connects
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: add Claude Code worktree mode for per-session filesystem isolation
- Add ANTHROPIC_API_KEY and AGENT_BRIDGE_CLAUDE_WORKTREE_ENABLED to docs
- Fix .env.example CLAUDE_TIMEOUT_SECONDS default (300 -> 600) to match code
- Add AGENT_BRIDGE_LOG_LEVEL to .env.example

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Code CLI also supports `claude login`, so the API key is not strictly required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Docs: sync env var tables across CLAUDE.md, README.md, and .env.example
CJK responses (3 bytes/char) slipped past the old char-based
SLACK_MSG_MAX_LENGTH check, hit msg_too_long mid-stream, and the
hard 500-char fallback left messages stuck at 528 chars.

- Switch to byte-based accounting with UTF-8-safe truncation
- Progressive fallback (3/4 -> 1/2 -> 1/4) instead of hard cut
- _upload_snippet returns bool; surface upload failures to users

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The heartbeat adapter fires a fixed prompt on a fixed interval, giving
the framework a proactive trigger source alongside the existing passive
chat platforms. Each tick is an independent session; the agent uses its
own tools to decide where output goes (the framework only logs events).

- Env-driven config: ENABLED / INTERVAL_MINUTES / PROMPT / STATE_PATH
- Restart catch-up via a JSON state file: fires immediately if
  last_run + interval has passed, otherwise sleeps to the next
  scheduled tick — at most one extra fire on startup
- Slack adapter is now independently optional; at least one platform
  must be configured

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: heartbeat platform for proactive agent triggering
The Claude controller treated every context as a chat-platform message,
so heartbeat ticks (added in htkuan#12) got a misleading "[unknown]:" prefix
and a system prompt claiming the conversation came from a chat platform.

_build_command now branches on context["source"]:

- heartbeat: prompt is passed through verbatim (no [tag]: prefix), and
  the appended system prompt tells Claude this is a scheduled invocation,
  no user is listening, AskUserQuestion will not be answered, and only
  side effects (files, external tool calls) persist beyond the audit log.
  The fired_at timestamp from the context is included.
- Chat platform context: existing behavior preserved.
- Empty / None context: no prefix, no --append-system-prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit on this branch still left the Claude controller
inspecting context["source"] / "user_name" / "channel_id" etc. to decide
how to tag the prompt and what system prompt to emit — every new
platform meant another branch in _build_command.

Move both responsibilities to the platform adapter:

- Add system_prompt: str | None to AgentController.run / Bridge.handle_message.
  The bridge forwards it as opaque pass-through; the agent must not parse
  context to construct it.
- Claude controller _build_command: passes prompt verbatim and appends
  --append-system-prompt iff system_prompt is non-empty. Zero platform
  knowledge.
- Slack adapter: pre-tags the prompt with [user_name (user_id)]: and
  builds the chat-platform system prompt itself, then passes both to the
  bridge.
- Heartbeat adapter: builds the heartbeat-flavored system prompt itself
  (scheduled invocation, no user listening, audit-only output, fired_at).

Adding a new platform now means writing those two strings inside the
adapter — agents and bridge stay untouched. Tests cover the new
controller pass-through contract, bridge forwarding, slack/heartbeat
helpers, and update existing stubs to the new signature. Docs updated:
CLAUDE.md, docs/agents/claude.md, docs/platforms/{slack,heartbeat}.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t prompt

Three connected improvements on top of the platform-agnostic-agent split:

1. Bridge.handle_message gains resumable: bool = True. When False, the
   bridge mints a fresh ephemeral UUID and skips SessionManager entirely
   — the call leaves no trace on disk. Heartbeat now passes
   resumable=False, so sessions.json stops accumulating heartbeat:tick:*
   entries that are never resumed anyway. Semantics: the platform
   answers "can the same key resume the same session later?" — yes for
   chat threads, no for proactive one-shot triggers.

2. Heartbeat system prompt trimmed to its essentials: the mechanism
   name, its purpose, and the fire time. Drops the no-user / audit /
   persistence directives — they were verbose and the agent can infer
   most of it from "scheduled tick".

3. Claude controller now logs the full command (cmd, not cmd[:5]) so
   --session-id / --resume / --permission-mode / -w /
   --append-system-prompt are all visible for debugging. Prompt content
   is still in the log; revisit if it becomes a leakage concern.

Tests cover both resumable branches (store written vs untouched, repeat
calls with the same key produce distinct session ids) and verify
heartbeat sets resumable=False. Docs updated in CLAUDE.md and
docs/platforms/heartbeat.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor: platform-built prompt + system prompt; agent stays platform-agnostic
Adds AGENT_BRIDGE_CLAUDE_EFFORT env var to pass --effort to claude -p.
Defaults to xhigh when unset or empty; validated against the CLI's
allowed levels (low/medium/high/xhigh/max) at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: configurable Claude Code effort level
The Slack adapter was overwriting the streamed accumulated text with
claude -p's `result` field on Completion. That field only contains the
last assistant turn's text, so any text emitted before tool calls
(typically the main reply) disappeared from the final Slack message.

- Prefer `accumulated_text` over `final_text`; the streamed accumulation
  is the full transcript the user already saw mid-stream.
- Insert a blank line between distinct text blocks so paragraphs across
  tool calls don't visually butt together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s-text

Fix: Slack adapter dropping main reply when followed by tool calls
cleanup_session calls `git worktree remove`, which refuses when the
worktree contains untracked or modified files. The previous behavior
logged a warning and gave up, leaving the worktree on disk forever.

In production this resulted in 91 stale worktrees consuming 3.3 GB of
the matt VM's 30 GB boot disk over four days.

Sessions are ephemeral — any uncommitted state at cleanup time is
disposable — so on a clean-remove failure we now retry with --force.
The final force-failure path still leaves the worktree for inspection.
…rce-remove

Fix: force-remove worktrees with untracked or modified files on cleanup
Fetch the bot's user_id and display name via auth.test() at startup
and embed them into the agent's system prompt as
"You are: {name} ({U…}) — Slack mention: <@U…>". Lets the agent
recognize @-mentions of itself and identify its own role in
multi-bot threads.

Also tightens the system prompt: "chat platform" → "Slack",
drops the redundant "Platform: slack" line, and removes the
unused "platform" key from context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Slack app's display name and the AI agent's persona are
independent — surfacing the bot's name (e.g. "agent_bridge") in
"You are: ..." could mislead the agent about its own identity
when the deployer chose a different app name from the agent role.

Keep only the bot user_id, surfaced as `Your Slack mention: <@U…>`
so the agent can detect when users @-mention it. Drop the
`bot_user_name` field, context key, and system-prompt framing.
The display name is still logged at startup for operator visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: surface bot identity in Slack adapter system prompt
Suppress repeated identical (or near-identical) prompts arriving in
different sessions within a TTL window. Motivated by alerter integrations
that fan one underlying error into many separate threads, each of which
would otherwise spin up its own agent run — multiplying disk I/O, agent
cost, and worktree churn.

Lives entirely in the Bridge layer so all platforms benefit. Two-stage
matching: canonicalize (regex-mask URLs, UUIDs, timestamps, emails, IPs,
long hex, long numbers) then exact match on the canonical form. Optional
SimHash fuzzy fallback when AGENT_BRIDGE_DEDUPE_SIMHASH_THRESHOLD > 0.

Hits short-circuit before session_id mint and semaphore acquire, yielding
a Completion with metadata["dedupe"] ∈ {"in_flight", "recent_hit"} and
first_session_key for the original investigation. Controller exceptions
and capacity rejections release the cache slot so retries proceed.

Disabled by default (AGENT_BRIDGE_DEDUPE_TTL_SECONDS=0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most real controller failures (timeout, non-zero exit, API error) are
reported via Completion(is_error=True) rather than raising. Treating
those as successful completions kept the cache entry for the full TTL,
locking out retries with a pointer back to the failed run.

Now the bridge tracks the last Completion's is_error flag and calls
mark_failed on error, mark_completed only on clean success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat: cross-session prompt dedupe at the bridge layer
ClaudeController._read_stream_with_timeout ended the stdout read loop only
on EOF. When a task leaves a backgrounded grandchild alive (e.g. a nested
`claude -p` or `until ...` poll loop spawned by a skill), that grandchild
inherits the bridge<->claude stdout pipe and keeps its write-end open after
the main `claude` process has produced its `result` and exited. readline()
never sees EOF, so the run blocks until the overall timeout fires —
spuriously logging "timed out" for a task that actually succeeded and
pinning a global concurrency slot for the full timeout window (up to 1h in
prod), starving the semaphore.

Fix in controller.py:
1. Break the read loop on the terminal `result` event, not on EOF. It is
   always the last meaningful line, so nothing is lost.
2. Kill the whole process group up front in `finally` (SIGTERM -> wait 5s
   -> SIGKILL) so an orphaned grandchild can't wedge process.wait() / the
   stderr drain on the still-open pipes; bound the stderr drain with a
   short timeout as a backstop.
3. Suppress the non-zero "exited with code N" Completion once a `result`
   has been seen — a post-completion signal exit is just our own group
   teardown, not a task failure. A genuine pre-result crash still reports.

Adds a regression test driving run() against a fake `claude` that emits a
`result` then leaves a backgrounded sleep holding stdout open: hangs to the
timeout on main, completes in <3s with a success Completion after the fix,
and the orphan is reaped with the group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esult

Fix: break Claude stdout loop on result event to free wedged concurrency slots
Restrict which Slack channels can reach the agent via a new
AGENT_BRIDGE_SLACK_ALLOW_CHANNELS env var (comma-separated channel
names, #/whitespace/case normalized). Empty = allow all (unchanged
default).

When the list is non-empty, messages from unlisted channels — and DMs,
which have no name — get a fixed reply instead of reaching the agent.
The reply text is configurable via
AGENT_BRIDGE_SLACK_CHANNEL_NOT_ALLOWED_MESSAGE.

The gate runs first in _process_message and resolves the channel name
through the existing cached conversations.info lookup, costing at most
one API call per distinct channel.

Docs (.env.example, CLAUDE.md, docs/platforms/slack.md) and tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
htkuan and others added 8 commits June 5, 2026 01:15
Extract the full token usage from Claude's `result` event (input/output,
cache read/creation, num_turns, duration_api_ms) and surface it as an
opt-in usage/cost footnote under each Slack reply.

Layered split:
- Agent maps Claude's raw `usage` into canonical keys in
  `Completion.metadata["usage"]` (cost/duration stay first-class fields).
- Bridge owns the generic `Usage` type, assembles it via
  `Usage.from_completion`, and accumulates an in-memory per-session total
  (`session_usage`). The running total is only reported for sessions
  tracked from their first turn (`is_new`) — partial totals after a
  restart or for pre-existing sessions are left None rather than shown
  misleadingly low. Purge drops the accumulator via `forget_session_usage`.
- Slack adapter owns the toggle + rendering:
  `AGENT_BRIDGE_SLACK_USAGE_REPORT_ENABLED` gates it,
  `AGENT_BRIDGE_SLACK_USAGE_REPORT_TEMPLATE` allows a `{placeholder}`
  template (else a default layout), rendered as an italic footnote beneath
  a labelled `─────cost─────` divider.

The footer is treated as inline-only metadata: it's kept out of the
upload-size decision and the uploaded snippet, and always rendered inline
(it survives truncation) so an oversized reply uploads the body alone while
the footer still shows in the preview.

Docs (.env.example, CLAUDE.md, bridge/slack/claude) and tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feat: surface Claude usage/cost as a Slack footer
Add a python-semantic-release pipeline that, on push to main, computes the
next version from Conventional Commits, tags vX.Y.Z, writes CHANGELOG.md, and
publishes to PyPI via Trusted Publishing (OIDC). Enforce the format with a
commitlint PR check plus an optional local pre-commit commit-msg hook sharing
the same config. Document the flow and one-time setup in docs/releasing.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the account-specific, already-completed One-time setup steps with a
short generic Maintainer setup note. The bootstrap (v0.1.0 baseline tag) is done
and the branch-protection step does not apply, so the steady-state guide should
not carry them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci: automate releases from Conventional Commits
Automatically generated by python-semantic-release
… 404s

When the agent posts Block Kit messages with interactive buttons and a
user clicks one, Slack sends a block_actions event. Without a handler,
Slack Bolt returns 404 "unhandled request" and the user sees an error.

Register a catch-all action(re.compile(".*")) listener that:
1. Calls ack() immediately (prevents the 3-second timeout error)
2. Extracts the button value/text and routes it as a message to the
   same thread/session, so the agent can continue the conversation

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@cresclaber
cresclaber requested a review from htkuan June 9, 2026 18:07
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.

2 participants