Skip to content

Commit 2de2b06

Browse files
committed
test+chore: close coverage gaps from multi-agent PR review
A 4-agent review found no correctness bugs in the diff; these address the coverage gaps and one dead-code item it surfaced. - daemon: test the real _run_agent_cycle_guarded timeout path -- a slow agent times out, its goal is abandoned and it's freed to IDLE, while a sibling completes (timeout isolation, previously only covered by an inline reimpl). - daemon: test max_concurrent_agents validator (>=1) + default. - store: test partial-migration recovery -- a half-applied migration (column added, user_version still 0) finishes cleanly on re-run via the column guards. - clipboard: test _read_from_system_clipboard subprocess result handling (decoded stdout on success, None on nonzero return) with a faked subprocess. - agent: test that a stream missing the terminal DONE event fails cleanly. - api.py: drop the now-unused logger/logging (leftover from the removed _bounded_run monkeypatch). Gate: 913 tests, ruff, format, mypy all green.
1 parent 3cde392 commit 2de2b06

5 files changed

Lines changed: 129 additions & 5 deletions

File tree

src/hive/api.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import asyncio
6-
import logging
76
from pathlib import Path
87
from typing import Any
98
from uuid import uuid4
@@ -15,8 +14,6 @@
1514
from hive.errors import AgentNotFoundError
1615
from hive.memory.store import HiveStore
1716

18-
logger = logging.getLogger(__name__)
19-
2017

2118
def _run_sync(coro: Any) -> Any:
2219
"""Run an async coroutine synchronously."""

tests/memory/test_store.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ async def test_upgrade_from_v0_adds_columns_and_keeps_data(self, tmp_path: Path)
116116
assert {"spawned_by", "max_cycles", "cycles_lived"} <= cols
117117
assert row is not None and row[0] == "Ada" # pre-existing data survived
118118

119+
@pytest.mark.asyncio
120+
async def test_partial_migration_recovers_on_rerun(self, tmp_path: Path) -> None:
121+
"""A half-applied migration (some columns added, version still 0) recovers.
122+
123+
The column-existence guards exist precisely so re-running initialize()
124+
finishes the migration instead of erroring on the already-added column.
125+
"""
126+
db_path = tmp_path / "state.db"
127+
async with aiosqlite.connect(db_path) as db:
128+
await db.executescript(_OLD_AGENTS_SCHEMA)
129+
# Simulate a crash mid-_migration_1: spawned_by added, the rest not,
130+
# and user_version never bumped.
131+
await db.execute("ALTER TABLE agents ADD COLUMN spawned_by TEXT")
132+
await db.execute("PRAGMA user_version = 0")
133+
await db.commit()
134+
135+
await HiveStore(db_path).initialize() # must not raise on the existing column
136+
137+
async with aiosqlite.connect(db_path) as db:
138+
version = (await (await db.execute("PRAGMA user_version")).fetchone())[0]
139+
cursor = await db.execute("PRAGMA table_info(agents)")
140+
cols = {row[1] for row in await cursor.fetchall()}
141+
142+
assert version == LATEST_SCHEMA_VERSION
143+
assert {"spawned_by", "max_cycles", "cycles_lived"} <= cols
144+
119145
@pytest.mark.asyncio
120146
async def test_initialize_is_idempotent(self, tmp_path: Path) -> None:
121147
"""Initializing twice is a no-op and does not error or change the version."""

tests/runtime/test_agent.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,32 @@ async def generate_stream(self, *args: Any, **kwargs: Any):
428428
yield StreamEvent(type=StreamEventType.DONE, result=result)
429429

430430

431+
class NoDoneProvider(BaseProvider):
432+
"""Streams text but never emits the terminal DONE event (a broken provider)."""
433+
434+
@property
435+
def available(self) -> bool:
436+
return True
437+
438+
async def generate_with_metadata(self, *args: Any, **kwargs: Any) -> GenerateResult:
439+
return GenerateResult(message=Message.assistant("x"), model="nodone")
440+
441+
async def generate_stream(self, *args: Any, **kwargs: Any):
442+
yield StreamEvent(type=StreamEventType.TEXT, text="partial")
443+
# intentionally no DONE event
444+
445+
431446
class TestStreaming:
432447
"""A2: the agent's optional on_text path forwards streamed text deltas."""
433448

449+
@pytest.mark.asyncio
450+
async def test_stream_without_done_event_fails_cleanly(self):
451+
"""A stream missing the terminal DONE event surfaces a clear failure."""
452+
agent = Agent(name="x", model=NoDoneProvider("nodone"), on_text=lambda t: None)
453+
result = await agent.run(Task(instruction="hi"))
454+
assert result.status == TaskStatus.FAILED
455+
assert "DONE" in (result.error or "")
456+
434457
@pytest.mark.asyncio
435458
async def test_on_text_via_base_default(self):
436459
"""A non-streaming provider still drives on_text via the base default."""

tests/test_daemon_concurrency.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ async def _seed(store: HiveStore, agent_id: str) -> None:
2626
)
2727

2828

29-
def _daemon(tmp_path: Path, max_concurrent: int) -> HiveDaemon:
29+
def _daemon(tmp_path: Path, max_concurrent: int, cycle_timeout: int = 0) -> HiveDaemon:
3030
# The daemon loads config in __init__, so set the global config AFTER
3131
# construction (read by _run at runtime) and disable economy on the instance.
3232
daemon = HiveDaemon(tmp_path / ".hive", heartbeat=0, logs_dir=tmp_path / "logs")
3333
cfg = HiveConfig()
3434
cfg.daemon.max_concurrent_agents = max_concurrent
35-
cfg.daemon.cycle_timeout = 0 # no per-cycle timeout in these tests
35+
cfg.daemon.cycle_timeout = cycle_timeout
3636
set_config(cfg)
3737
daemon._economy_enabled = False # avoid life-event provider calls
3838
return daemon
@@ -92,3 +92,48 @@ async def fake_cycle(agent: AgentState) -> str:
9292
# The failing agent was isolated and marked ERROR.
9393
boom = await daemon._store.get_agent("boom")
9494
assert boom is not None and boom.status == AgentStatus.ERROR
95+
96+
@pytest.mark.asyncio
97+
async def test_timeout_isolates_slow_agent(self, tmp_path: Path) -> None:
98+
"""A timed-out cycle abandons that agent's goal and frees it; siblings finish.
99+
100+
Drives the real _run_agent_cycle_guarded timeout path (not an inline copy).
101+
"""
102+
daemon = _daemon(tmp_path, max_concurrent=8, cycle_timeout=1)
103+
await daemon._store.initialize()
104+
for aid in ["slow", "ok"]:
105+
await _seed(daemon._store, aid)
106+
await daemon._store.save_goal("g-slow", "slow", "a goal that will time out")
107+
108+
ran: set[str] = set()
109+
110+
async def fake_cycle(agent: AgentState) -> str:
111+
if agent.agent_id == "slow":
112+
await asyncio.sleep(5) # exceeds the 1s cycle_timeout -> cancelled
113+
return "completed"
114+
ran.add(agent.agent_id)
115+
return "completed"
116+
117+
daemon._run_agent_cycle = fake_cycle # type: ignore[method-assign]
118+
await daemon.start(max_cycles=1)
119+
120+
assert ran == {"ok"} # the healthy agent completed despite the slow sibling
121+
slow = await daemon._store.get_agent("slow")
122+
assert slow is not None and slow.status == AgentStatus.IDLE # freed, not stuck
123+
goal = await daemon._store.get_goal_by_id("g-slow")
124+
assert goal is not None and goal["status"] == "abandoned"
125+
126+
127+
class TestConcurrencyConfig:
128+
def test_max_concurrent_agents_must_be_positive(self) -> None:
129+
from pydantic import ValidationError
130+
131+
from hive.config import DaemonConfig
132+
133+
with pytest.raises(ValidationError, match="max_concurrent_agents"):
134+
DaemonConfig(max_concurrent_agents=0)
135+
136+
def test_max_concurrent_agents_default(self) -> None:
137+
from hive.config import DaemonConfig
138+
139+
assert DaemonConfig().max_concurrent_agents == 8

tests/tools/test_clipboard_read.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,36 @@ async def test_read_clipboard_exposed_as_tool() -> None:
4848
async def test_helper_unsupported_platform(monkeypatch):
4949
monkeypatch.setattr(cb.platform, "system", lambda: "Windows")
5050
assert await cb._read_from_system_clipboard() is None
51+
52+
53+
class _FakeProc:
54+
def __init__(self, returncode: int, stdout: bytes) -> None:
55+
self.returncode = returncode
56+
self._stdout = stdout
57+
58+
async def communicate(self):
59+
return self._stdout, b""
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_helper_reads_subprocess_stdout(monkeypatch):
64+
"""The macOS/Linux path returns decoded stdout when the command succeeds."""
65+
monkeypatch.setattr(cb.platform, "system", lambda: "Darwin")
66+
67+
async def fake_exec(*cmd, **kwargs):
68+
return _FakeProc(0, b"copied text\n")
69+
70+
monkeypatch.setattr(cb.asyncio, "create_subprocess_exec", fake_exec)
71+
assert await cb._read_from_system_clipboard() == "copied text\n"
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_helper_nonzero_returncode_is_none(monkeypatch):
76+
"""A failed clipboard command yields None (never raises)."""
77+
monkeypatch.setattr(cb.platform, "system", lambda: "Linux")
78+
79+
async def fake_exec(*cmd, **kwargs):
80+
return _FakeProc(1, b"")
81+
82+
monkeypatch.setattr(cb.asyncio, "create_subprocess_exec", fake_exec)
83+
assert await cb._read_from_system_clipboard() is None

0 commit comments

Comments
 (0)