Skip to content
Closed
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
11 changes: 11 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5246,6 +5246,17 @@ def _clarify_callback_sync(question: str, choices, multi_select: bool = False) -

timeout = _clarify_mod.get_clarify_timeout()
response = _clarify_mod.wait_for_response(clarify_id, timeout=float(timeout))
# A /stop or interrupt-mode message unblocks wait_for_response
# via the per-thread interrupt flag (#83889). Surface that as an
# explicit interrupt sentinel rather than a misleading timeout
# message — the agent is already in the interrupted state and
# will wind the turn down accordingly.
try:
from tools.interrupt import is_interrupted as _clarify_is_interrupted
except Exception: # pragma: no cover - optional
_clarify_is_interrupted = lambda: False
if _clarify_is_interrupted():
return "[interrupted by user]"
if response is None or response == "":
# Timeout or session-boundary cancellation
return f"[user did not respond within {int(timeout / 60)}m]"
Expand Down
28 changes: 28 additions & 0 deletions tests/tools/test_clarify_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ def waiter():
assert result == ""


def test_wait_for_response_unblocks_on_interrupt(self):
"""A /stop or interrupt-mode message must unblock the wait well
before the timeout. ``wait_for_response`` runs on the agent thread,
so signalling the per-thread interrupt flag (what the gateway's
interrupt path does) must make it return immediately instead of
blocking the full clarify timeout (#83889)."""
from tools import clarify_gateway as cm
from tools.interrupt import clear_current_thread_interrupt, set_interrupt

tid = threading.current_thread().ident
cm.register("id-int", "sk-int", "Pick one", ["A", "B"])

def signal_interrupt():
time.sleep(0.05)
set_interrupt(True, tid)

threading.Thread(target=signal_interrupt).start()
start = time.monotonic()
result = cm.wait_for_response("id-int", timeout=10.0)
elapsed = time.monotonic() - start
clear_current_thread_interrupt()

assert result is None
assert elapsed < 5.0, f"wait blocked {elapsed:.1f}s despite interrupt"
# The entry is cleaned up on the interrupt exit path, same as timeout.
assert cm.get_pending_for_session("sk-int") is None


def test_notify_register_unregister_clears_pending(self):
"""unregister_notify cancels any pending clarify so threads unwind."""
from tools import clarify_gateway as cm
Expand Down
11 changes: 11 additions & 0 deletions tools/clarify_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,22 @@ def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
except Exception: # pragma: no cover - optional
touch_activity_if_due = None

# Per-thread interrupt flag: /stop and interrupt-mode messages set it on
# the agent thread (tools.interrupt), and the wait loop below must observe
# it or the agent stays blocked until the full clarify timeout even though
# the run has been cancelled (#83889).
try:
from tools.interrupt import is_interrupted
except Exception: # pragma: no cover - optional
is_interrupted = lambda: False

# 0 / negative → unlimited: no deadline, poll forever in 1s slices.
unlimited = timeout is None or float(timeout) <= 0.0
deadline = None if unlimited else time.monotonic() + float(timeout)
activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
while True:
if is_interrupted():
break
if deadline is None:
slice_s = 1.0
else:
Expand Down