|
40 | 40 |
|
41 | 41 | logger = get_logger(__name__) |
42 | 42 |
|
| 43 | +# In-process thread guard to avoid duplicate handling on edits/rapid events |
| 44 | +_thread_locks: dict[str, asyncio.Lock] = {} |
| 45 | + |
43 | 46 |
|
44 | 47 | def get_designated_channel_for_workspace(team_id: str) -> str | None: |
45 | 48 | """Get the designated channel ID for a given workspace team ID.""" |
@@ -137,6 +140,36 @@ async def handle_message(payload: SlackPayload, db: Database): |
137 | 140 | return Completed(message="Message too long", name="SKIPPED") |
138 | 141 |
|
139 | 142 | if re.search(BOT_MENTION, user_message) and payload.authorizations: |
| 143 | + # Use the root thread timestamp as our idempotency key |
| 144 | + root_ts = thread_ts |
| 145 | + |
| 146 | + # Acquire per-thread lock; if already locked, another handler is running |
| 147 | + lock = _thread_locks.setdefault(root_ts, asyncio.Lock()) |
| 148 | + if lock.locked(): |
| 149 | + # If this is an edit to the original message, politely ignore |
| 150 | + if event.subtype == "message_changed": |
| 151 | + assert event.channel is not None, "No channel found" |
| 152 | + await post_slack_message( |
| 153 | + message=( |
| 154 | + "✋ I noticed you edited your original message. " |
| 155 | + "I'm already working on your first version — please add any " |
| 156 | + "clarifications as new messages in this thread so I don't lose track." |
| 157 | + ), |
| 158 | + channel_id=event.channel, |
| 159 | + thread_ts=root_ts, |
| 160 | + ) |
| 161 | + return Completed( |
| 162 | + message="Ignored edit while in progress", |
| 163 | + name="IGNORED_EDIT", |
| 164 | + data=dict(thread_ts=root_ts), |
| 165 | + ) |
| 166 | + # Otherwise, just skip duplicate events while busy |
| 167 | + return Completed( |
| 168 | + message="Skipped duplicate event while in progress", |
| 169 | + name="SKIPPED_DUPLICATE", |
| 170 | + data=dict(thread_ts=root_ts), |
| 171 | + ) |
| 172 | + |
140 | 173 | # Check if this is the designated channel |
141 | 174 | team_id = payload.team_id or "" |
142 | 175 | is_designated = check_if_designated_channel(event.channel, team_id) |
@@ -186,37 +219,49 @@ async def handle_message(payload: SlackPayload, db: Database): |
186 | 219 | bot_id=bot_user_id or "unknown", |
187 | 220 | ) |
188 | 221 |
|
189 | | - try: |
190 | | - result = await run_agent( |
191 | | - cleaned_message, conversation, user_context, event.channel, thread_ts |
192 | | - ) # type: ignore |
193 | | - |
194 | | - await db.add_thread_messages(thread_ts, result.new_messages()) |
195 | | - conversation.extend(result.new_messages()) |
196 | | - assert event.channel is not None, "No channel found" |
197 | | - await task(post_slack_message)( |
198 | | - message=result.output, |
199 | | - channel_id=event.channel, |
200 | | - thread_ts=thread_ts, |
201 | | - ) |
202 | | - except Exception as e: |
203 | | - logger.error(f"Error running agent: {e}") |
204 | | - assert event.channel is not None, "No channel found" |
205 | | - await task(post_slack_message)( |
206 | | - message="Sorry, I encountered an error while processing your request. Please try again.", |
207 | | - channel_id=event.channel, |
208 | | - thread_ts=thread_ts, |
209 | | - ) |
210 | | - # Still return completed so we don't retry |
| 222 | + async with lock: |
| 223 | + try: |
| 224 | + result = await run_agent( |
| 225 | + cleaned_message, |
| 226 | + conversation, |
| 227 | + user_context, |
| 228 | + event.channel, |
| 229 | + thread_ts, |
| 230 | + ) # type: ignore |
| 231 | + |
| 232 | + await db.add_thread_messages(thread_ts, result.new_messages()) |
| 233 | + conversation.extend(result.new_messages()) |
| 234 | + assert event.channel is not None, "No channel found" |
| 235 | + await task(post_slack_message)( |
| 236 | + message=result.output, |
| 237 | + channel_id=event.channel, |
| 238 | + thread_ts=thread_ts, |
| 239 | + ) |
| 240 | + except Exception as e: |
| 241 | + logger.error(f"Error running agent: {e}") |
| 242 | + assert event.channel is not None, "No channel found" |
| 243 | + await task(post_slack_message)( |
| 244 | + message="Sorry, I encountered an error while processing your request. Please try again.", |
| 245 | + channel_id=event.channel, |
| 246 | + thread_ts=thread_ts, |
| 247 | + ) |
| 248 | + # Still return completed so we don't retry |
| 249 | + return Completed( |
| 250 | + message="Error during agent execution", |
| 251 | + name="ERROR_HANDLED", |
| 252 | + data=dict(error=str(e), user_context=user_context), |
| 253 | + ) |
| 254 | + finally: |
| 255 | + # Clean up lock map to avoid unbounded growth |
| 256 | + # Only pop if this lock is the same instance |
| 257 | + current = _thread_locks.get(root_ts) |
| 258 | + if current is lock: |
| 259 | + _thread_locks.pop(root_ts, None) |
| 260 | + |
211 | 261 | return Completed( |
212 | | - message="Error during agent execution", |
213 | | - name="ERROR_HANDLED", |
214 | | - data=dict(error=str(e), user_context=user_context), |
| 262 | + message="Responded to mention", |
| 263 | + data=dict(user_context=user_context, conversation=conversation), |
215 | 264 | ) |
216 | | - return Completed( |
217 | | - message="Responded to mention", |
218 | | - data=dict(user_context=user_context, conversation=conversation), |
219 | | - ) |
220 | 265 |
|
221 | 266 | return Completed(message="Skipping non-mention", name="SKIPPED") |
222 | 267 |
|
|
0 commit comments