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)
This commit is contained in:
teknium1
2026-09-12 17:09:23 -07:00
parent 96431d8239
commit 4d886fa904
3 changed files with 58 additions and 2 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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: