omnigent-ai--omnigent
190 行
6.3 KiB
Python
190 行
6.3 KiB
Python
"""Tests for the native-pane idle reaper (issue #1349)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent.terminals.pane_reaper import (
|
|
_DEFAULT_IDLE_TIMEOUT_S,
|
|
_IDLE_TIMEOUT_ENV,
|
|
NativePaneReaper,
|
|
PaneRef,
|
|
resolve_native_pane_idle_timeout_s,
|
|
)
|
|
|
|
|
|
def _pane(conv: str, name: str = "claude") -> PaneRef:
|
|
return PaneRef(conv, f"terminal:{name}:main", name, Path(f"/tmp/omni-test/{conv}.sock"))
|
|
|
|
|
|
class _Fakes:
|
|
"""Mutable test doubles so a test can flip busy/panes between scans."""
|
|
|
|
def __init__(self) -> None:
|
|
self.panes: list[PaneRef] = []
|
|
self.busy: set[str] = set()
|
|
self.reaped: list[str] = []
|
|
# Optional per-call override: (pane, call_index) -> bool. Lets a test make
|
|
# is_busy answer differently on the classify pass vs the re-check pass.
|
|
self.busy_override: Callable[[PaneRef, int], bool] | None = None
|
|
self.busy_calls = 0
|
|
|
|
async def is_busy(self, pane: PaneRef) -> bool:
|
|
self.busy_calls += 1
|
|
if self.busy_override is not None:
|
|
return self.busy_override(pane, self.busy_calls)
|
|
return pane.conversation_id in self.busy
|
|
|
|
async def reap(self, pane: PaneRef) -> None:
|
|
self.reaped.append(pane.conversation_id)
|
|
self.panes = [p for p in self.panes if p.conversation_id != pane.conversation_id]
|
|
|
|
|
|
def _make(fakes: _Fakes, *, timeout: float = 100.0, interval: float = 0.01) -> NativePaneReaper:
|
|
return NativePaneReaper(
|
|
list_native_panes=lambda: list(fakes.panes),
|
|
is_busy=fakes.is_busy,
|
|
reap=fakes.reap,
|
|
idle_timeout_s=timeout,
|
|
reaper_interval_s=interval,
|
|
)
|
|
|
|
|
|
# ── Pure idle-clock decision (_classify) ────────────────────────────────────
|
|
|
|
|
|
def test_classify_reaps_only_after_full_window() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
f.panes = [p]
|
|
r = _make(f, timeout=100.0)
|
|
assert r._classify(1000.0, [p], busy_convs=set()) == [] # first obs: grace
|
|
assert r._classify(1099.0, [p], busy_convs=set()) == [] # 99s < 100s
|
|
assert r._classify(1100.0, [p], busy_convs=set()) == [p] # window elapsed
|
|
|
|
|
|
def test_classify_busy_rearms_clock() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
r = _make(f, timeout=10.0)
|
|
r._classify(0.0, [p], busy_convs={"conv_a"})
|
|
assert r._classify(1000.0, [p], busy_convs={"conv_a"}) == [] # busy re-arms
|
|
r._classify(1000.0, [p], busy_convs=set()) # now idle, grace
|
|
assert r._classify(1010.0, [p], busy_convs=set()) == [p]
|
|
|
|
|
|
def test_classify_first_observation_grace() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
r = _make(f, timeout=0.001)
|
|
assert r._classify(5.0, [p], busy_convs=set()) == [] # clock seeded this pass
|
|
|
|
|
|
def test_classify_forgets_gone_panes() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
r = _make(f, timeout=10.0)
|
|
r._classify(0.0, [p], busy_convs=set())
|
|
assert "conv_a" in r._last_busy_at
|
|
r._classify(1.0, [], busy_convs=set()) # pane gone
|
|
assert "conv_a" not in r._last_busy_at
|
|
|
|
|
|
# ── Scan behaviour (_scan_once): reap, skip-busy, TOCTOU re-check ────────────
|
|
|
|
|
|
async def test_scan_reaps_idle_unbusy_pane() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
f.panes = [p]
|
|
r = _make(f, timeout=10.0)
|
|
r._last_busy_at["conv_a"] = time.monotonic() - 1000 # already idle past window
|
|
await r._scan_once()
|
|
assert f.reaped == ["conv_a"]
|
|
|
|
|
|
async def test_scan_skips_busy_pane() -> None:
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
f.panes = [p]
|
|
f.busy = {"conv_a"}
|
|
r = _make(f, timeout=10.0)
|
|
r._last_busy_at["conv_a"] = time.monotonic() - 1000
|
|
await r._scan_once()
|
|
assert f.reaped == [] # busy → not reaped, clock re-armed
|
|
|
|
|
|
async def test_scan_recheck_spares_pane_that_became_busy() -> None:
|
|
"""TOCTOU guard: a pane idle at selection but busy at the pre-reap re-check
|
|
must NOT be reaped (a turn/client/autonomous run started in between)."""
|
|
f = _Fakes()
|
|
p = _pane("conv_a")
|
|
f.panes = [p]
|
|
# is_busy: False on the classify-phase call (call 1), True on the re-check
|
|
# call (call 2) — simulating a turn starting between selection and teardown.
|
|
f.busy_override = lambda pane, n: n >= 2
|
|
r = _make(f, timeout=10.0)
|
|
r._last_busy_at["conv_a"] = time.monotonic() - 1000
|
|
await r._scan_once()
|
|
assert f.reaped == [] # spared by the re-check
|
|
assert f.busy_calls == 2 # classify + re-check
|
|
|
|
|
|
# ── Env resolver ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_resolve_default_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv(_IDLE_TIMEOUT_ENV, raising=False)
|
|
assert resolve_native_pane_idle_timeout_s() == float(_DEFAULT_IDLE_TIMEOUT_S)
|
|
|
|
|
|
def test_resolve_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv(_IDLE_TIMEOUT_ENV, "120")
|
|
assert resolve_native_pane_idle_timeout_s() == 120.0
|
|
|
|
|
|
def test_resolve_zero_disables(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv(_IDLE_TIMEOUT_ENV, "0")
|
|
assert resolve_native_pane_idle_timeout_s() == 0.0
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["abc", "-5", ""])
|
|
def test_resolve_invalid_falls_back(monkeypatch: pytest.MonkeyPatch, bad: str) -> None:
|
|
monkeypatch.setenv(_IDLE_TIMEOUT_ENV, bad)
|
|
assert resolve_native_pane_idle_timeout_s() == float(_DEFAULT_IDLE_TIMEOUT_S)
|
|
|
|
|
|
# ── Loop smoke (start/shutdown + disable) ───────────────────────────────────
|
|
|
|
|
|
async def test_loop_reaps_idle_pane() -> None:
|
|
f = _Fakes()
|
|
f.panes = [_pane("conv_a")]
|
|
r = _make(f, timeout=0.0001, interval=0.01)
|
|
await r.start()
|
|
try:
|
|
for _ in range(100):
|
|
if f.reaped:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
finally:
|
|
await r.shutdown()
|
|
assert f.reaped == ["conv_a"]
|
|
|
|
|
|
async def test_loop_disabled_when_timeout_non_positive() -> None:
|
|
f = _Fakes()
|
|
f.panes = [_pane("conv_a")]
|
|
r = _make(f, timeout=0.0, interval=0.01) # 0 disables
|
|
await r.start()
|
|
try:
|
|
await asyncio.sleep(0.1)
|
|
finally:
|
|
await r.shutdown()
|
|
assert f.reaped == []
|