Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/capabilities/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ Whenever a reply will take more than ~2 seconds β€” i.e. *any* reply that involv

This is unconditional. Sam doesn't decide whether the work "warrants" a status β€” if there's a tool call, there's a status. (The skill `src/skills/slack-dynamic-messaging/skill.md` covers the live-UX features that remain judgment calls β€” streamed text replies, rotating loading messages, feedback buttons. In-flight *progress* rendering is no longer one of them: the runtime draws it as a plan block automatically from `ack`/`set_status` β€” see below.)

## Cancel β€” `:no_entry:` reaction
## Cancel β€” `:no_entry:` and other cancel reactions

**Anyone in the channel** can cancel an in-flight session by adding the `:no_entry:` reaction to the original message that triggered Sam. The daemon's `reaction_added` handler matches on (any human user + :no_entry: + the live lifecycle target) and cancels the running session task. The bot's own `:no_entry:` stamps (which the cleanup adds as the terminal lifecycle reaction) are filtered out explicitly. Cleanup is automatic: lifecycle stamps `:no_entry:` as the terminal reaction, the daemon posts a brief "cancelled" note in the thread, and the journal entry records `status: cancelled` with the last_failure_signature if any.
**Anyone in the channel** can cancel an in-flight session by adding a cancel reactionβ€”such as `:no_entry:` (β›”), `:x:` (❌), `:no_entry_sign:` (🚫), `:octagonal_sign:` (πŸ›‘), or `:heavy_multiplication_x:` (βœ–οΈ)β€”to the original message that triggered Sam. The daemon's `reaction_added` handler matches on (any human user + a cancel reaction + the live lifecycle target) and cancels the running session task. The bot's own reactions (which the cleanup adds as the terminal lifecycle reaction) are filtered out explicitly. Cleanup is automatic: lifecycle stamps `:no_entry:` as the terminal reaction (retaining the distinction of a cancelled state), the daemon posts a brief "cancelled" note in the thread, and the journal entry records `status: cancelled` with the last_failure_signature if any.

This is a deliberately broad affordance β€” Sam works in a shared channel; anyone seeing it head down a wrong path should be able to stop it without needing the principal operator. The blast radius is bounded (one session, terminal state, queue continues normally).

Sam doesn't need to do anything for this β€” the cancel is event-driven via the existing Slack reaction subscription, no polling. But Sam reading the journal later should recognize `status: cancelled` as distinct from `errored` / `timed_out` / `stuck`: it means a teammate stopped the work deliberately. Don't auto-retry a cancelled session.

With parallel sessions, the reaction targets the specific session: `:no_entry:` on a message cancels the session answering *that* message (including any message of a coalesced batch, and follow-ups that were steered into a running session) β€” other in-flight sessions keep running.
With parallel sessions, the reaction targets the specific session: any of the cancel reactions on a message cancels the session answering *that* message (including any message of a coalesced batch, and follow-ups that were steered into a running session) β€” other in-flight sessions keep running.

## Concurrency β€” parallel sessions, serial threads, steering

Expand Down
42 changes: 24 additions & 18 deletions src/runtime/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def _format_session_badges(result: SessionResult) -> str:
# messages would be re-queued by reaction-recovery on next
# boot, defeating the cancel.
_TERMINAL_REACTIONS = ("white_check_mark", "x", "no_entry")
_CANCEL_REACTIONS = ("no_entry", "x", "no_entry_sign", "octagonal_sign", "heavy_multiplication_x")

# Non-terminal reactions that recovery may need to clear when self-healing
# a stale candidate (lifecycle was interrupted between work-completion and
Expand Down Expand Up @@ -319,6 +320,7 @@ class _SessionSlot:
message: IncomingMessage
thread_key: Optional[tuple[str, str]] = None
cancel_reactor: Optional[str] = None
cancel_reaction: Optional[str] = None
steered_messages: list[IncomingMessage] = field(default_factory=list)


Expand Down Expand Up @@ -1103,6 +1105,7 @@ async def _handle_operator_cancel(
sam_session: "SamSession",
message: IncomingMessage,
cancelled_by: Optional[str] = None,
cancel_reaction: Optional[str] = None,
) -> None:
"""Cleanup when anyone in the channel cancels a live session
via the `:no_entry:` reaction.
Expand Down Expand Up @@ -1208,7 +1211,7 @@ async def _handle_operator_cancel(
agent="_daemon",
tool="_operator_cancel",
args={
"reaction": "no_entry",
"reaction": cancel_reaction or "no_entry",
# The Slack user_id of whoever actually reacted. Anyone
# in the channel can cancel, so attributing this to
# `SAM_OPERATOR_USER_ID` would silently misattribute
Expand All @@ -1219,7 +1222,7 @@ async def _handle_operator_cancel(
"event_ts": message.event_ts,
},
output=(
f"Session cancelled by {cancelled_by} via :no_entry: "
f"Session cancelled by {cancelled_by} via :{cancel_reaction or 'no_entry'}: "
"reaction on the original message."
),
started_at=time.time(),
Expand Down Expand Up @@ -1282,23 +1285,23 @@ async def on_assistant_thread_started(event, client):
@self.app.event("reaction_added")
async def on_reaction_added(event, client):
log.info("reaction added: %s on %s", event.get("reaction"), event.get("item"))
# Cancel: `:no_entry:` from ANY human user on the live
# lifecycle message (the original Slack mention) cancels the
# Cancel: any reaction in `_CANCEL_REACTIONS` from ANY human user on the
# live lifecycle message (the original Slack mention) cancels the
# in-flight session. Anyone in the channel can stop Sam β€”
# this is a team-wide affordance, not an operator-only one.
# Bot's own :no_entry: stamps (added by cleanup) are filtered
# out explicitly. Event-driven via the existing Slack
# reaction subscription β€” no polling. The cancel is honored
# at the next natural await boundary inside the session's
# task; cleanup posts a brief note and stamps :no_entry: as
# the terminal lifecycle reaction.
if event.get("reaction") != "no_entry":
# Bot's own reactions (added by cleanup) are filtered out
# explicitly. Event-driven via the existing Slack reaction
# subscription β€” no polling. The cancel is honored at the next
# natural await boundary inside the session's task; cleanup posts
# a brief note and stamps :no_entry: as the terminal lifecycle reaction.
reaction = event.get("reaction")
if reaction not in _CANCEL_REACTIONS:
return
user_id = event.get("user")
if not user_id:
return
if self.bot_user_id and user_id == self.bot_user_id:
# The bot itself stamping :no_entry: during cleanup
# The bot itself stamping reactions during cleanup
# doesn't count as a cancel signal.
return
item = event.get("item") or {}
Expand All @@ -1311,20 +1314,21 @@ async def on_reaction_added(event, client):
slot = self._active_sessions.get((target_channel, target_ts))
if slot is None or slot.task.done():
log.info(
"operator-cancel :no_entry: on %s/%s matches no live session; ignoring",
target_channel, target_ts,
"operator-cancel :%s: on %s/%s matches no live session; ignoring",
reaction, target_channel, target_ts,
)
return
log.info(
"cancel :no_entry: matches live session (reactor=%s) β€” cancelling task",
user_id,
"cancel :%s: matches live session (reactor=%s) β€” cancelling task",
reaction, user_id,
)
# Stash the reactor's user_id on the slot for the cleanup path.
# Stash the reactor's user_id and reaction on the slot for the cleanup path.
# The cancellation propagates through `task.cancel()` β†’ next
# await in the worker β†’ `_handle_operator_cancel`, none of
# which carry the event payload; the slot is the per-session
# channel that survives the asyncio handoff.
slot.cancel_reactor = user_id
slot.cancel_reaction = reaction
slot.task.cancel()

@self.app.event("reaction_removed")
Expand Down Expand Up @@ -2299,7 +2303,9 @@ async def _run_slack_session(
# write a journal stub with status=cancelled, advance
# the cursor so the message doesn't re-fire on boot.
await self._handle_operator_cancel(
first, message, cancelled_by=slot.cancel_reactor,
first, message,
cancelled_by=slot.cancel_reactor,
cancel_reaction=slot.cancel_reaction,
)
return

Expand Down
106 changes: 106 additions & 0 deletions tests/runtime/test_multi_reaction_cancel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from __future__ import annotations

import asyncio
from unittest.mock import MagicMock
import pytest

from src.runtime.daemon import Daemon, _CANCEL_REACTIONS
from src.runtime.session import IncomingMessage


class FakeApp:
def __init__(self):
self._listeners = {}

def event(self, name):
def decorator(fn):
self._listeners[name] = fn
return fn
return decorator


@pytest.mark.asyncio
async def test_on_reaction_added_cancels_matching_session(monkeypatch):
# 1. Create a minimal Daemon instance
d = Daemon.__new__(Daemon)
d.bot_user_id = "BOT123"
d.app = FakeApp()
d._active_sessions = {}

# 2. Register handlers
Daemon._register_handlers(d)
reaction_handler = d.app._listeners.get("reaction_added")
assert reaction_handler is not None, "reaction_added handler was not registered"

# 3. Create a fake in-flight task and slot
async def dummy_task_fn():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
raise

task = asyncio.create_task(dummy_task_fn())

# Simple FakeSession
class FakeSession:
session_id = "sess_123"

message = IncomingMessage(
channel="C_TEST", user="U1", text="test",
thread_ts=None, event_ts="100.0001",
)

# Create the slot
from src.runtime.daemon import _SessionSlot
slot = _SessionSlot(
task=task,
session=FakeSession(),
message=message,
)
d._active_sessions[("C_TEST", "100.0001")] = slot

# 4. Trigger with a non-cancel reaction -> should NOT cancel
event_ok = {
"reaction": "thumbsup",
"user": "U_USER",
"item": {"type": "message", "channel": "C_TEST", "ts": "100.0001"}
}
await reaction_handler(event_ok, None)
assert not task.cancelled()
assert slot.cancel_reactor is None
assert slot.cancel_reaction is None

# 5. Trigger with bot's own cancel reaction -> should NOT cancel
event_bot = {
"reaction": "no_entry",
"user": "BOT123",
"item": {"type": "message", "channel": "C_TEST", "ts": "100.0001"}
}
await reaction_handler(event_bot, None)
assert not task.cancelled()
assert slot.cancel_reactor is None
assert slot.cancel_reaction is None

# 6. Trigger with each of the valid cancel reactions -> should cancel
for reaction in _CANCEL_REACTIONS:
# Reset slot/task for next check
sub_task = asyncio.create_task(dummy_task_fn())
slot.task = sub_task
slot.cancel_reactor = None
slot.cancel_reaction = None

event_cancel = {
"reaction": reaction,
"user": "U_OPERATOR",
"item": {"type": "message", "channel": "C_TEST", "ts": "100.0001"}
}
await reaction_handler(event_cancel, None)

# Give task a moment to process the cancel
await asyncio.sleep(0.001)
assert sub_task.cancelled()
assert slot.cancel_reactor == "U_OPERATOR"
assert slot.cancel_reaction == reaction

# Cleanup leftover tasks
task.cancel()
2 changes: 1 addition & 1 deletion tests/runtime/test_parallel_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ async def test_cancel_routes_to_the_right_parallel_session(monkeypatch):

cancels = []

async def fake_cancel(sam_session, message, cancelled_by=None):
async def fake_cancel(sam_session, message, cancelled_by=None, **kwargs):
cancels.append((message.event_ts, cancelled_by))

d._handle_operator_cancel = fake_cancel
Expand Down
Loading