tiny-computer-agent / tests /test_watchdog.py
hkblockchaintechnology's picture
Tests: watchdog, audit and the AT-SPI guard
03c1ae9 verified
Raw History Blame Contribute Delete
6.17 kB
"""A call that never returns must not be a harness that never returns.
The loop stops a *repeated* action and a step budget stops a long episode, but nothing
stopped a single call that hangs: no step, no reason, no evidence, and a console that looks
like it is thinking. These tests pin the watchdog's two promises - the loop stops waiting and
says which call it stopped waiting for - and its one honest limit, that a Python thread
cannot be killed, only abandoned.
"""
import time
import pytest
from tiny_computer_agent import (
AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Watchdog, WatchdogTimeout,
)
# -- the unit: one guarded call ------------------------------------------------ #
def test_a_call_that_returns_passes_its_value_through():
watchdog = Watchdog(step_timeout=5)
assert watchdog.call("decide", lambda a, b: a + b, 2, 3) == 5
assert watchdog.orphans == []
def test_a_call_that_overruns_is_abandoned_and_named():
watchdog = Watchdog(step_timeout=0.05)
started = time.monotonic()
with pytest.raises(WatchdogTimeout) as raised:
watchdog.call("execute", lambda: time.sleep(3))
elapsed = time.monotonic() - started
assert elapsed < 1.5, "the watchdog waited for the deadline, not for the call"
assert raised.value.where == "execute"
assert raised.value.seconds == 0.05
assert "execute did not finish within 0.05s" in str(raised.value)
assert watchdog.orphans == ["execute"], "an abandoned call is recorded, not forgiven"
def test_the_wall_clock_refuses_the_next_call_before_it_starts():
"""A spent wall clock is reported as un-attempted, never as a timeout of what was next."""
watchdog = Watchdog(step_timeout=5, wall_clock=0).start()
called = []
with pytest.raises(WatchdogTimeout) as raised:
watchdog.call("decide", lambda: called.append("ran"))
assert called == [], "nothing may be attempted once the wall clock is spent"
assert raised.value.abandoned is False
assert "not attempted" in str(raised.value)
def test_no_per_call_deadline_runs_the_call_inline():
"""The honest default for a local desktop: this process *is* the thing being watched."""
watchdog = Watchdog(step_timeout=None)
assert watchdog.call("execute", lambda: "inline") == "inline"
assert watchdog.threads_started == 0
def test_an_exception_inside_a_guarded_call_reaches_the_caller_unchanged():
def broken():
raise ValueError("the executor is broken, not slow")
with pytest.raises(ValueError, match="not slow"):
Watchdog(step_timeout=5).call("execute", broken)
def test_render_names_the_limits_and_every_abandoned_call():
watchdog = Watchdog(step_timeout=0.05, wall_clock=60).start()
with pytest.raises(WatchdogTimeout):
watchdog.call("decide", lambda: time.sleep(3))
line = watchdog.render()
assert "per call <= 0.05s" in line
assert "wall clock <= 60s" in line
assert "abandoned: decide" in line
def test_the_clock_starts_once_and_is_not_restarted_by_a_re_run():
watchdog = Watchdog(wall_clock=60).start()
time.sleep(0.02)
first = watchdog.elapsed
watchdog.start() # re-running a goal must not hand it a fresh budget
assert watchdog.elapsed >= first
def test_remaining_counts_down_and_then_reports_expired():
watchdog = Watchdog(wall_clock=0.05).start()
assert watchdog.remaining is not None and watchdog.remaining <= 0.05
time.sleep(0.06)
assert watchdog.expired is True
assert Watchdog(wall_clock=None).remaining is None
# -- the loop: a hung stage becomes a step, not a stall ------------------------- #
class SlowExecutor(DryRunExecutor):
seconds = 2.0
def execute(self, action, observation):
time.sleep(self.seconds)
return super().execute(action, observation)
class SlowScreen(FakeScreen):
def capture(self):
time.sleep(2.0)
return super().capture()
class SlowBrain(MockBrain):
def decide(self, goal, observation):
time.sleep(2.0)
return super().decide(goal, observation)
def _screen():
return FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
def test_a_hung_executor_ends_the_episode_with_a_named_reason():
loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "click", "label": "Save"}]),
executor=SlowExecutor(), watchdog=Watchdog(step_timeout=0.05))
started = time.monotonic()
episode = loop.run("click 'Save' to save the document")
assert time.monotonic() - started < 2.0, "the loop must not wait for the hung call"
assert episode.success is False
assert episode.stop_reason.startswith("watchdog stopped the episode")
assert "execute did not finish within 0.05s" in episode.steps[-1].error
assert loop.watchdog.orphans == ["execute"]
def test_a_hung_brain_ends_the_episode_too():
loop = AgentLoop(screen=_screen(), brain=SlowBrain([{"kind": "done", "reason": "x"}]),
watchdog=Watchdog(step_timeout=0.05))
episode = loop.run("save")
assert episode.success is False
assert "decide did not finish within 0.05s" in episode.steps[-1].error
assert loop.watchdog.orphans == ["decide"]
def test_a_hung_capture_is_recorded_before_the_episode_starts():
loop = AgentLoop(screen=SlowScreen([(Element("Save", x=1, y=1, width=10, height=10),)]),
brain=MockBrain([{"kind": "done", "reason": "x"}]),
watchdog=Watchdog(step_timeout=0.05))
episode = loop.run("save")
assert episode.success is False
assert "watchdog stopped the episode" in episode.stop_reason
assert "capture did not finish within 0.05s" in episode.stop_reason
def test_without_a_watchdog_a_slow_but_finite_call_still_completes():
"""Guarding is opt-in: a local desktop should not pay for a thread per step."""
loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "done", "reason": "fine"}]),
executor=SlowExecutor())
loop.executor.seconds = 0.05
episode = loop.run("save")
assert episode.success is True
assert loop.watchdog is None