From 4d886fa9041a8aef99c0b007f97aedefc940ee91 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:09:23 -0700 Subject: [PATCH] fix(computer-use): wait_for_human returns no_takeover when nobody answers the handoff wait_for_release polled until the full timeout (10 min by default) even when the holder never left AGENT, so an unseen request_handoff blocked the turn instead of letting the model chase the user in chat. After `grace` seconds (param, default 60, capped at the timeout) with the handoff still pending and no human holding, return {ok: false, code: no_takeover}. Once a human holds the screen the full timeout still applies to the hand-back. (cherry picked from commit 1402af036044a7191ec885fc8fa5c24206880909) --- tests/tools/test_computer_use_handoff_wait.py | 38 +++++++++++++++++++ tools/bot_desktop/lease.py | 12 +++++- tools/computer_use/handoff.py | 10 +++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_computer_use_handoff_wait.py diff --git a/tests/tools/test_computer_use_handoff_wait.py b/tests/tools/test_computer_use_handoff_wait.py new file mode 100644 index 0000000000..29811d8580 --- /dev/null +++ b/tests/tools/test_computer_use_handoff_wait.py @@ -0,0 +1,38 @@ +"""wait_for_human answers an unanswered handoff early instead of blocking the whole timeout, and still +waits the full timeout once a human actually holds the screen.""" + +from __future__ import annotations + +import json +import threading +import time + +import pytest + +from tools.bot_desktop import lease +from tools.computer_use.handoff import handle_handoff + + +@pytest.fixture(autouse=True) +def _fresh_lease(): + lease._reset_for_tests() + yield + lease._reset_for_tests() + + +def test_wait_for_human_returns_no_takeover_when_nobody_answers_but_waits_out_a_real_takeover(): + handle_handoff("request_handoff", {"reason": "log in"}) + t0 = time.monotonic() + res = json.loads(handle_handoff("wait_for_human", {"seconds": 30, "grace": 0.2})) + assert res["code"] == "no_takeover" and res["state"]["pending_handoff"] == "log in" + assert time.monotonic() - t0 < 10, "an unanswered request must not run the full timeout" + + # A human takes over inside the grace window and hands back later: the wait outlives the grace. + def _take_then_release(): + time.sleep(0.1) + lease.acquire("viewer-1") + time.sleep(0.6) + lease.release("viewer-1") + threading.Thread(target=_take_then_release, daemon=True).start() + res = json.loads(handle_handoff("wait_for_human", {"seconds": 30, "grace": 0.3})) + assert res["ok"] and res["state"]["holder"] == lease.AGENT diff --git a/tools/bot_desktop/lease.py b/tools/bot_desktop/lease.py index 9aea7a63d8..33b12f2009 100644 --- a/tools/bot_desktop/lease.py +++ b/tools/bot_desktop/lease.py @@ -190,11 +190,19 @@ def wait_for_release(*, timeout: float, profile_key: Optional[str] = None) -> bo """Block until the agent holds the lease (and no handoff is pending) or ``timeout`` elapses. True when control is back with the agent. Polls the file so a release made by another process is seen; the local Condition just shortens the wait for same-process transitions.""" + return _wait_until(lambda lease: lease.holder == AGENT and lease.pending_handoff is None, timeout, profile_key) + + +def wait_for_takeover_or_release(*, timeout: float, profile_key: Optional[str] = None) -> bool: + """False when, after ``timeout``, the agent still holds with a handoff pending: nobody answered.""" + return _wait_until(lambda lease: lease.holder == HUMAN or lease.pending_handoff is None, timeout, profile_key) + + +def _wait_until(done: Callable[[Lease], bool], timeout: float, profile_key: Optional[str]) -> bool: path = _path(profile_key) deadline = time.monotonic() + timeout while True: - lease = _read(path) - if lease.holder == AGENT and lease.pending_handoff is None: + if done(_read(path)): return True remaining = deadline - time.monotonic() if remaining <= 0: diff --git a/tools/computer_use/handoff.py b/tools/computer_use/handoff.py index 11e30d788a..bdd38acbb1 100644 --- a/tools/computer_use/handoff.py +++ b/tools/computer_use/handoff.py @@ -18,6 +18,7 @@ from tools.bot_desktop import lease as _lease HANDOFF_ACTIONS = frozenset({"request_handoff", "wait_for_human"}) _DEFAULT_WAIT_SECONDS = 600.0 _MAX_WAIT_SECONDS = 1800.0 +_DEFAULT_GRACE_SECONDS = 60.0 def handle_handoff(action: str, args: Dict[str, Any]) -> str: @@ -31,6 +32,15 @@ def handle_handoff(action: str, args: Dict[str, Any]) -> str: "computer_use action='wait_for_human' to block until they hand control back and " "re-capture before continuing — the screen state is whatever they left."}) timeout = min(_MAX_WAIT_SECONDS, max(1.0, float(args.get("seconds") or _DEFAULT_WAIT_SECONDS))) + grace = min(timeout, max(0.0, float(args.get("grace") or _DEFAULT_GRACE_SECONDS))) + # Nobody has taken over yet: give them `grace` to click Take over, then return so the model can chase + # the user in chat instead of blocking the whole timeout on a request nobody saw. Once a human holds + # the screen, wait the full timeout for the hand-back. + if not _lease.wait_for_takeover_or_release(timeout=grace): + state = _lease.get().as_dict() + return json.dumps({"ok": False, "action": action, "code": "no_takeover", "state": state, + "error": f"Nobody took over within {grace:.0f}s. Ask the user in chat to open Bots > Screen and " + "click Take over, then call wait_for_human again."}) released = _lease.wait_for_release(timeout=timeout) state = _lease.get().as_dict() if released: