Compare commits
1 Commits
main
...
gemini/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e4206a91e |
@@ -33,12 +33,12 @@ from dashboard.routes.calm import router as calm_router
|
||||
from dashboard.routes.chat_api import router as chat_api_router
|
||||
from dashboard.routes.chat_api_v1 import router as chat_api_v1_router
|
||||
from dashboard.routes.daily_run import router as daily_run_router
|
||||
from dashboard.routes.hermes import router as hermes_router
|
||||
from dashboard.routes.db_explorer import router as db_explorer_router
|
||||
from dashboard.routes.discord import router as discord_router
|
||||
from dashboard.routes.experiments import router as experiments_router
|
||||
from dashboard.routes.grok import router as grok_router
|
||||
from dashboard.routes.health import router as health_router
|
||||
from dashboard.routes.hermes import router as hermes_router
|
||||
from dashboard.routes.loop_qa import router as loop_qa_router
|
||||
from dashboard.routes.memory import router as memory_router
|
||||
from dashboard.routes.mobile import router as mobile_router
|
||||
|
||||
@@ -41,6 +41,7 @@ def _save_voice_settings(data: dict) -> None:
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save voice settings: %s", exc)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||
|
||||
@@ -4,6 +4,6 @@ Monitors the local machine (Hermes/M3 Max) for memory pressure, disk usage,
|
||||
Ollama model health, zombie processes, and network connectivity.
|
||||
"""
|
||||
|
||||
from infrastructure.hermes.monitor import HermesMonitor, HealthLevel, HealthReport, hermes_monitor
|
||||
from infrastructure.hermes.monitor import HealthLevel, HealthReport, HermesMonitor, hermes_monitor
|
||||
|
||||
__all__ = ["HermesMonitor", "HealthLevel", "HealthReport", "hermes_monitor"]
|
||||
|
||||
@@ -19,11 +19,12 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
@@ -31,7 +32,7 @@ from config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthLevel(str, Enum):
|
||||
class HealthLevel(StrEnum):
|
||||
"""Severity level for a health check result."""
|
||||
|
||||
OK = "ok"
|
||||
@@ -194,8 +195,7 @@ class HermesMonitor:
|
||||
name="memory",
|
||||
level=HealthLevel.CRITICAL,
|
||||
message=(
|
||||
f"Critical: only {free_gb:.1f}GB free "
|
||||
f"(threshold: {memory_free_min_gb}GB)"
|
||||
f"Critical: only {free_gb:.1f}GB free (threshold: {memory_free_min_gb}GB)"
|
||||
),
|
||||
details=details,
|
||||
needs_human=True,
|
||||
@@ -302,8 +302,7 @@ class HermesMonitor:
|
||||
name="disk",
|
||||
level=HealthLevel.CRITICAL,
|
||||
message=(
|
||||
f"Critical: only {free_gb:.1f}GB free "
|
||||
f"(threshold: {disk_free_min_gb}GB)"
|
||||
f"Critical: only {free_gb:.1f}GB free (threshold: {disk_free_min_gb}GB)"
|
||||
),
|
||||
details=details,
|
||||
needs_human=True,
|
||||
@@ -335,7 +334,7 @@ class HermesMonitor:
|
||||
cutoff = time.time() - 86400 # 24 hours ago
|
||||
|
||||
try:
|
||||
tmp = Path("/tmp")
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
for item in tmp.iterdir():
|
||||
try:
|
||||
stat = item.stat()
|
||||
@@ -345,11 +344,7 @@ class HermesMonitor:
|
||||
freed_bytes += stat.st_size
|
||||
item.unlink(missing_ok=True)
|
||||
elif item.is_dir():
|
||||
dir_size = sum(
|
||||
f.stat().st_size
|
||||
for f in item.rglob("*")
|
||||
if f.is_file()
|
||||
)
|
||||
dir_size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file())
|
||||
freed_bytes += dir_size
|
||||
shutil.rmtree(str(item), ignore_errors=True)
|
||||
except (PermissionError, OSError):
|
||||
@@ -392,10 +387,7 @@ class HermesMonitor:
|
||||
return CheckResult(
|
||||
name="ollama",
|
||||
level=HealthLevel.OK,
|
||||
message=(
|
||||
f"Ollama OK — {len(models)} model(s) available, "
|
||||
f"{len(loaded)} loaded"
|
||||
),
|
||||
message=(f"Ollama OK — {len(models)} model(s) available, {len(loaded)} loaded"),
|
||||
details={
|
||||
"reachable": True,
|
||||
"model_count": len(models),
|
||||
|
||||
@@ -135,7 +135,9 @@ class BannerlordObserver:
|
||||
self._host = host or settings.gabs_host
|
||||
self._port = port or settings.gabs_port
|
||||
self._timeout = timeout if timeout is not None else settings.gabs_timeout
|
||||
self._poll_interval = poll_interval if poll_interval is not None else settings.gabs_poll_interval
|
||||
self._poll_interval = (
|
||||
poll_interval if poll_interval is not None else settings.gabs_poll_interval
|
||||
)
|
||||
self._journal_path = Path(journal_path) if journal_path else _get_journal_path()
|
||||
self._entry_count = 0
|
||||
self._days_observed: set[str] = set()
|
||||
|
||||
@@ -196,9 +196,7 @@ class EmotionalStateTracker:
|
||||
"intensity_label": _intensity_label(self.state.intensity),
|
||||
"previous_emotion": self.state.previous_emotion,
|
||||
"trigger_event": self.state.trigger_event,
|
||||
"prompt_modifier": EMOTION_PROMPT_MODIFIERS.get(
|
||||
self.state.current_emotion, ""
|
||||
),
|
||||
"prompt_modifier": EMOTION_PROMPT_MODIFIERS.get(self.state.current_emotion, ""),
|
||||
}
|
||||
|
||||
def get_prompt_modifier(self) -> str:
|
||||
|
||||
@@ -36,7 +36,7 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -70,7 +70,9 @@ _LOOP_TAG = "loop-generated"
|
||||
|
||||
# Regex patterns for scoring
|
||||
_TAG_RE = re.compile(r"\[([^\]]+)\]")
|
||||
_FILE_RE = re.compile(r"(?:src/|tests/|scripts/|\.py|\.html|\.js|\.yaml|\.toml|\.sh)", re.IGNORECASE)
|
||||
_FILE_RE = re.compile(
|
||||
r"(?:src/|tests/|scripts/|\.py|\.html|\.js|\.yaml|\.toml|\.sh)", re.IGNORECASE
|
||||
)
|
||||
_FUNC_RE = re.compile(r"(?:def |class |function |method |`\w+\(\)`)", re.IGNORECASE)
|
||||
_ACCEPT_RE = re.compile(
|
||||
r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria"
|
||||
@@ -451,9 +453,7 @@ async def add_label(
|
||||
|
||||
# Apply to the issue
|
||||
apply_url = _repo_url(f"issues/{issue_number}/labels")
|
||||
apply_resp = await client.post(
|
||||
apply_url, headers=headers, json={"labels": [label_id]}
|
||||
)
|
||||
apply_resp = await client.post(apply_url, headers=headers, json={"labels": [label_id]})
|
||||
return apply_resp.status_code in (200, 201)
|
||||
|
||||
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
|
||||
@@ -692,7 +692,9 @@ class BacklogTriageLoop:
|
||||
# 1. Fetch
|
||||
raw_issues = await fetch_open_issues(client)
|
||||
result.total_open = len(raw_issues)
|
||||
logger.info("Triage cycle #%d: fetched %d open issues", self._cycle_count, len(raw_issues))
|
||||
logger.info(
|
||||
"Triage cycle #%d: fetched %d open issues", self._cycle_count, len(raw_issues)
|
||||
)
|
||||
|
||||
# 2. Score
|
||||
scored = [score_issue(i) for i in raw_issues]
|
||||
|
||||
@@ -37,7 +37,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
@@ -48,7 +48,8 @@ logger = logging.getLogger(__name__)
|
||||
# Enumerations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentType(str, Enum):
|
||||
|
||||
class AgentType(StrEnum):
|
||||
"""Known agents in the swarm."""
|
||||
|
||||
CLAUDE_CODE = "claude_code"
|
||||
@@ -57,7 +58,7 @@ class AgentType(str, Enum):
|
||||
TIMMY = "timmy"
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
class TaskType(StrEnum):
|
||||
"""Categories of engineering work."""
|
||||
|
||||
# Claude Code strengths
|
||||
@@ -83,7 +84,7 @@ class TaskType(str, Enum):
|
||||
ORCHESTRATION = "orchestration"
|
||||
|
||||
|
||||
class DispatchStatus(str, Enum):
|
||||
class DispatchStatus(StrEnum):
|
||||
"""Lifecycle state of a dispatched task."""
|
||||
|
||||
PENDING = "pending"
|
||||
@@ -99,6 +100,7 @@ class DispatchStatus(str, Enum):
|
||||
# Agent registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSpec:
|
||||
"""Capabilities and limits for a single agent."""
|
||||
@@ -106,9 +108,9 @@ class AgentSpec:
|
||||
name: AgentType
|
||||
display_name: str
|
||||
strengths: frozenset[TaskType]
|
||||
gitea_label: str | None # label to apply when dispatching
|
||||
gitea_label: str | None # label to apply when dispatching
|
||||
max_concurrent: int = 1
|
||||
interface: str = "gitea" # "gitea" | "api" | "local"
|
||||
interface: str = "gitea" # "gitea" | "api" | "local"
|
||||
api_endpoint: str | None = None # for interface="api"
|
||||
|
||||
|
||||
@@ -197,6 +199,7 @@ _TASK_ROUTING: dict[TaskType, AgentType] = {
|
||||
# Dispatch result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
"""Outcome of a dispatch call."""
|
||||
@@ -220,6 +223,7 @@ class DispatchResult:
|
||||
# Routing logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def select_agent(task_type: TaskType) -> AgentType:
|
||||
"""Return the best agent for *task_type* based on the routing table.
|
||||
|
||||
@@ -248,11 +252,23 @@ def infer_task_type(title: str, description: str = "") -> TaskType:
|
||||
text = (title + " " + description).lower()
|
||||
|
||||
_SIGNALS: list[tuple[TaskType, frozenset[str]]] = [
|
||||
(TaskType.ARCHITECTURE, frozenset({"architect", "design", "adr", "system design", "schema"})),
|
||||
(TaskType.REFACTORING, frozenset({"refactor", "clean up", "cleanup", "reorganise", "reorganize"})),
|
||||
(
|
||||
TaskType.ARCHITECTURE,
|
||||
frozenset({"architect", "design", "adr", "system design", "schema"}),
|
||||
),
|
||||
(
|
||||
TaskType.REFACTORING,
|
||||
frozenset({"refactor", "clean up", "cleanup", "reorganise", "reorganize"}),
|
||||
),
|
||||
(TaskType.CODE_REVIEW, frozenset({"review", "pr review", "pull request review", "audit"})),
|
||||
(TaskType.COMPLEX_REASONING, frozenset({"complex", "hard problem", "debug", "investigate", "diagnose"})),
|
||||
(TaskType.RESEARCH, frozenset({"research", "survey", "literature", "benchmark", "analyse", "analyze"})),
|
||||
(
|
||||
TaskType.COMPLEX_REASONING,
|
||||
frozenset({"complex", "hard problem", "debug", "investigate", "diagnose"}),
|
||||
),
|
||||
(
|
||||
TaskType.RESEARCH,
|
||||
frozenset({"research", "survey", "literature", "benchmark", "analyse", "analyze"}),
|
||||
),
|
||||
(TaskType.ANALYSIS, frozenset({"analysis", "profil", "trace", "metric", "performance"})),
|
||||
(TaskType.TRIAGE, frozenset({"triage", "classify", "prioritise", "prioritize"})),
|
||||
(TaskType.PLANNING, frozenset({"plan", "roadmap", "milestone", "epic", "spike"})),
|
||||
@@ -273,6 +289,7 @@ def infer_task_type(title: str, description: str = "") -> TaskType:
|
||||
# Gitea helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _post_gitea_comment(
|
||||
client: Any,
|
||||
base_url: str,
|
||||
@@ -405,6 +422,7 @@ async def _poll_issue_completion(
|
||||
# Core dispatch functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _dispatch_via_gitea(
|
||||
agent: AgentType,
|
||||
issue_number: int,
|
||||
@@ -479,7 +497,11 @@ async def _dispatch_via_gitea(
|
||||
)
|
||||
|
||||
# 2. Post assignment comment
|
||||
criteria_md = "\n".join(f"- {c}" for c in acceptance_criteria) if acceptance_criteria else "_None specified_"
|
||||
criteria_md = (
|
||||
"\n".join(f"- {c}" for c in acceptance_criteria)
|
||||
if acceptance_criteria
|
||||
else "_None specified_"
|
||||
)
|
||||
comment_body = (
|
||||
f"## Assigned to {spec.display_name}\n\n"
|
||||
f"**Task type:** `{task_type.value}`\n\n"
|
||||
@@ -616,9 +638,7 @@ async def _dispatch_local(
|
||||
assumed to succeed at dispatch time).
|
||||
"""
|
||||
task_type = infer_task_type(title, description)
|
||||
logger.info(
|
||||
"Timmy handling task locally: %r (issue #%s)", title[:60], issue_number
|
||||
)
|
||||
logger.info("Timmy handling task locally: %r (issue #%s)", title[:60], issue_number)
|
||||
return DispatchResult(
|
||||
task_type=task_type,
|
||||
agent=AgentType.TIMMY,
|
||||
@@ -632,6 +652,7 @@ async def _dispatch_local(
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def dispatch_task(
|
||||
title: str,
|
||||
description: str = "",
|
||||
@@ -769,9 +790,7 @@ async def _log_escalation(
|
||||
f"---\n*Timmy agent dispatcher.*"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
await _post_gitea_comment(
|
||||
client, base_url, repo, headers, issue_number, body
|
||||
)
|
||||
await _post_gitea_comment(client, base_url, repo, headers, issue_number, body)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to post escalation comment: %s", exc)
|
||||
|
||||
@@ -780,6 +799,7 @@ async def _log_escalation(
|
||||
# Monitoring helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def wait_for_completion(
|
||||
issue_number: int,
|
||||
poll_interval: int = 60,
|
||||
|
||||
@@ -418,9 +418,7 @@ class MCPBridge:
|
||||
return f"Error executing {name}: {exc}"
|
||||
|
||||
@staticmethod
|
||||
def _build_initial_messages(
|
||||
prompt: str, system_prompt: str | None
|
||||
) -> list[dict]:
|
||||
def _build_initial_messages(prompt: str, system_prompt: str | None) -> list[dict]:
|
||||
"""Build the initial message list for a run."""
|
||||
messages: list[dict] = []
|
||||
if system_prompt:
|
||||
@@ -512,9 +510,7 @@ class MCPBridge:
|
||||
error_msg = ""
|
||||
|
||||
try:
|
||||
content, tool_calls_made, rounds, error_msg = await self._run_tool_loop(
|
||||
messages, tools
|
||||
)
|
||||
content, tool_calls_made, rounds, error_msg = await self._run_tool_loop(messages, tools)
|
||||
except httpx.ConnectError as exc:
|
||||
logger.warning("Ollama connection failed: %s", exc)
|
||||
error_msg = f"Ollama connection failed: {exc}"
|
||||
|
||||
@@ -47,13 +47,11 @@ _DEFAULT_IDLE_THRESHOLD = 30
|
||||
class AgentStatus:
|
||||
"""Health snapshot for one agent at a point in time."""
|
||||
|
||||
agent: str # "claude" | "kimi" | "timmy"
|
||||
agent: str # "claude" | "kimi" | "timmy"
|
||||
is_idle: bool = True
|
||||
active_issue_numbers: list[int] = field(default_factory=list)
|
||||
stuck_issue_numbers: list[int] = field(default_factory=list)
|
||||
checked_at: str = field(
|
||||
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||
)
|
||||
checked_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
@property
|
||||
def is_stuck(self) -> bool:
|
||||
@@ -69,9 +67,7 @@ class AgentHealthReport:
|
||||
"""Combined health report for all monitored agents."""
|
||||
|
||||
agents: list[AgentStatus] = field(default_factory=list)
|
||||
generated_at: str = field(
|
||||
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||
)
|
||||
generated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
@property
|
||||
def any_stuck(self) -> bool:
|
||||
@@ -193,18 +189,14 @@ async def check_agent_health(
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
issues = await _fetch_labeled_issues(
|
||||
client, base_url, headers, repo, label
|
||||
)
|
||||
issues = await _fetch_labeled_issues(client, base_url, headers, repo, label)
|
||||
|
||||
for issue in issues:
|
||||
num = issue.get("number", 0)
|
||||
status.active_issue_numbers.append(num)
|
||||
|
||||
# Check last activity
|
||||
last_activity = await _last_comment_time(
|
||||
client, base_url, headers, repo, num
|
||||
)
|
||||
last_activity = await _last_comment_time(client, base_url, headers, repo, num)
|
||||
if last_activity is None:
|
||||
last_activity = await _issue_created_time(issue)
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ _PRIORITY_LABEL_SCORES: dict[str, int] = {
|
||||
class AgentTarget(StrEnum):
|
||||
"""Which agent should handle this issue."""
|
||||
|
||||
TIMMY = "timmy" # Timmy handles locally (self)
|
||||
TIMMY = "timmy" # Timmy handles locally (self)
|
||||
CLAUDE = "claude" # Dispatch to Claude Code
|
||||
KIMI = "kimi" # Dispatch to Kimi Code
|
||||
KIMI = "kimi" # Dispatch to Kimi Code
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -172,9 +172,7 @@ def triage_issues(raw_issues: list[dict[str, Any]]) -> list[TriagedIssue]:
|
||||
title = issue.get("title", "")
|
||||
body = issue.get("body") or ""
|
||||
labels = _extract_labels(issue)
|
||||
assignees = [
|
||||
a.get("login", "") for a in issue.get("assignees") or []
|
||||
]
|
||||
assignees = [a.get("login", "") for a in issue.get("assignees") or []]
|
||||
url = issue.get("html_url", "")
|
||||
|
||||
priority = _score_priority(labels, assignees)
|
||||
@@ -252,9 +250,7 @@ async def fetch_open_issues(
|
||||
params=params,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"fetch_open_issues: Gitea returned %s", resp.status_code
|
||||
)
|
||||
logger.warning("fetch_open_issues: Gitea returned %s", resp.status_code)
|
||||
return []
|
||||
|
||||
issues = resp.json()
|
||||
|
||||
@@ -34,7 +34,7 @@ _LABEL_MAP: dict[AgentTarget, str] = {
|
||||
|
||||
_LABEL_COLORS: dict[str, str] = {
|
||||
"claude-ready": "#8b6f47", # warm brown
|
||||
"kimi-ready": "#006b75", # dark teal
|
||||
"kimi-ready": "#006b75", # dark teal
|
||||
"timmy-ready": "#0075ca", # blue
|
||||
}
|
||||
|
||||
@@ -52,9 +52,7 @@ class DispatchRecord:
|
||||
issue_title: str
|
||||
agent: AgentTarget
|
||||
rationale: str
|
||||
dispatched_at: str = field(
|
||||
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||
)
|
||||
dispatched_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
label_applied: bool = False
|
||||
comment_posted: bool = False
|
||||
|
||||
@@ -170,9 +168,7 @@ async def dispatch_issue(issue: TriagedIssue) -> DispatchRecord:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
label_id = await _get_or_create_label(
|
||||
client, base_url, headers, repo, label_name
|
||||
)
|
||||
label_id = await _get_or_create_label(client, base_url, headers, repo, label_name)
|
||||
|
||||
# Apply label
|
||||
if label_id is not None:
|
||||
|
||||
@@ -22,9 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
# Thresholds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WARN_DISK_PCT = 85.0 # warn when disk is more than 85% full
|
||||
_WARN_MEM_PCT = 90.0 # warn when memory is more than 90% used
|
||||
_WARN_CPU_PCT = 95.0 # warn when CPU is above 95% sustained
|
||||
_WARN_DISK_PCT = 85.0 # warn when disk is more than 85% full
|
||||
_WARN_MEM_PCT = 90.0 # warn when memory is more than 90% used
|
||||
_WARN_CPU_PCT = 95.0 # warn when CPU is above 95% sustained
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -63,9 +63,7 @@ class SystemSnapshot:
|
||||
memory: MemoryUsage = field(default_factory=MemoryUsage)
|
||||
ollama: OllamaHealth = field(default_factory=OllamaHealth)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
taken_at: str = field(
|
||||
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||
)
|
||||
taken_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
@@ -117,8 +115,8 @@ def _probe_memory() -> MemoryUsage:
|
||||
def _probe_ollama_sync(ollama_url: str) -> OllamaHealth:
|
||||
"""Synchronous Ollama health probe — run in a thread."""
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
url = ollama_url.rstrip("/") + "/api/tags"
|
||||
with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
|
||||
@@ -154,14 +152,12 @@ async def get_system_snapshot() -> SystemSnapshot:
|
||||
|
||||
if disk.percent_used >= _WARN_DISK_PCT:
|
||||
warnings.append(
|
||||
f"Disk {disk.path}: {disk.percent_used:.0f}% used "
|
||||
f"({disk.free_gb:.1f} GB free)"
|
||||
f"Disk {disk.path}: {disk.percent_used:.0f}% used ({disk.free_gb:.1f} GB free)"
|
||||
)
|
||||
|
||||
if memory.percent_used >= _WARN_MEM_PCT:
|
||||
warnings.append(
|
||||
f"Memory: {memory.percent_used:.0f}% used "
|
||||
f"({memory.available_gb:.1f} GB available)"
|
||||
f"Memory: {memory.percent_used:.0f}% used ({memory.available_gb:.1f} GB available)"
|
||||
)
|
||||
|
||||
if not ollama.reachable:
|
||||
@@ -216,7 +212,5 @@ async def cleanup_stale_files(
|
||||
errors.append(str(exc))
|
||||
|
||||
await asyncio.to_thread(_cleanup)
|
||||
logger.info(
|
||||
"cleanup_stale_files: deleted %d files, %d errors", deleted, len(errors)
|
||||
)
|
||||
logger.info("cleanup_stale_files: deleted %d files, %d errors", deleted, len(errors))
|
||||
return {"deleted_count": deleted, "errors": errors}
|
||||
|
||||
@@ -10,14 +10,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.bannerlord.gabs_client import GabsClient, GabsError
|
||||
|
||||
|
||||
# ── GabsClient unit tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -236,7 +234,13 @@ class TestBannerlordObserver:
|
||||
|
||||
snapshot = {
|
||||
"game_state": {"day": 7, "season": "winter", "campaign_phase": "early"},
|
||||
"player": {"name": "Timmy", "clan": "Thalheimer", "renown": 42, "level": 3, "gold": 1000},
|
||||
"player": {
|
||||
"name": "Timmy",
|
||||
"clan": "Thalheimer",
|
||||
"renown": 42,
|
||||
"level": 3,
|
||||
"gold": 1000,
|
||||
},
|
||||
"player_party": {"size": 25, "morale": 80, "food_days_left": 5},
|
||||
"kingdoms": [{"name": "Vlandia", "ruler": "Derthert", "military_strength": 5000}],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for agent emotional state simulation (src/timmy/agents/emotional_state.py)."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from timmy.agents.emotional_state import (
|
||||
EMOTION_PROMPT_MODIFIERS,
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.dispatcher import (
|
||||
AGENT_REGISTRY,
|
||||
AgentType,
|
||||
@@ -21,11 +19,11 @@ from timmy.dispatcher import (
|
||||
wait_for_completion,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentRegistry:
|
||||
def test_all_agents_present(self):
|
||||
for member in AgentType:
|
||||
@@ -41,7 +39,7 @@ class TestAgentRegistry:
|
||||
assert spec.gitea_label, f"{agent} is gitea interface but has no label"
|
||||
|
||||
def test_non_gitea_agents_have_no_labels(self):
|
||||
for agent, spec in AGENT_REGISTRY.items():
|
||||
for _agent, spec in AGENT_REGISTRY.items():
|
||||
if spec.interface not in ("gitea",):
|
||||
# api and local agents may have no label
|
||||
assert spec.gitea_label is None or spec.interface == "gitea"
|
||||
@@ -55,6 +53,7 @@ class TestAgentRegistry:
|
||||
# select_agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSelectAgent:
|
||||
def test_architecture_routes_to_claude(self):
|
||||
assert select_agent(TaskType.ARCHITECTURE) == AgentType.CLAUDE_CODE
|
||||
@@ -85,6 +84,7 @@ class TestSelectAgent:
|
||||
# infer_task_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInferTaskType:
|
||||
def test_architecture_keyword(self):
|
||||
assert infer_task_type("Design the LLM router architecture") == TaskType.ARCHITECTURE
|
||||
@@ -119,6 +119,7 @@ class TestInferTaskType:
|
||||
# DispatchResult
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchResult:
|
||||
def test_success_when_assigned(self):
|
||||
r = DispatchResult(
|
||||
@@ -161,6 +162,7 @@ class TestDispatchResult:
|
||||
# _dispatch_local
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchLocal:
|
||||
async def test_returns_assigned(self):
|
||||
result = await _dispatch_local(
|
||||
@@ -190,6 +192,7 @@ class TestDispatchLocal:
|
||||
# _dispatch_via_api
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchViaApi:
|
||||
async def test_no_endpoint_returns_failed(self):
|
||||
result = await _dispatch_via_api(
|
||||
@@ -304,7 +307,9 @@ class TestDispatchViaGitea:
|
||||
assert result.status == DispatchStatus.ASSIGNED
|
||||
|
||||
async def test_no_gitea_token_returns_failed(self):
|
||||
bad_settings = MagicMock(gitea_enabled=True, gitea_token="", gitea_url="http://x", gitea_repo="a/b")
|
||||
bad_settings = MagicMock(
|
||||
gitea_enabled=True, gitea_token="", gitea_url="http://x", gitea_repo="a/b"
|
||||
)
|
||||
with patch("timmy.dispatcher.settings", bad_settings):
|
||||
result = await _dispatch_via_gitea(
|
||||
agent=AgentType.CLAUDE_CODE,
|
||||
@@ -317,7 +322,9 @@ class TestDispatchViaGitea:
|
||||
assert "not configured" in (result.error or "").lower()
|
||||
|
||||
async def test_gitea_disabled_returns_failed(self):
|
||||
bad_settings = MagicMock(gitea_enabled=False, gitea_token="tok", gitea_url="http://x", gitea_repo="a/b")
|
||||
bad_settings = MagicMock(
|
||||
gitea_enabled=False, gitea_token="tok", gitea_url="http://x", gitea_repo="a/b"
|
||||
)
|
||||
with patch("timmy.dispatcher.settings", bad_settings):
|
||||
result = await _dispatch_via_gitea(
|
||||
agent=AgentType.CLAUDE_CODE,
|
||||
@@ -368,6 +375,7 @@ class TestDispatchViaGitea:
|
||||
# dispatch_task (integration-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchTask:
|
||||
async def test_empty_title_returns_failed(self):
|
||||
result = await dispatch_task(title=" ")
|
||||
@@ -396,7 +404,9 @@ class TestDispatchTask:
|
||||
client_mock = AsyncMock()
|
||||
client_mock.__aenter__ = AsyncMock(return_value=client_mock)
|
||||
client_mock.__aexit__ = AsyncMock(return_value=False)
|
||||
client_mock.get = AsyncMock(return_value=MagicMock(status_code=200, json=MagicMock(return_value=[])))
|
||||
client_mock.get = AsyncMock(
|
||||
return_value=MagicMock(status_code=200, json=MagicMock(return_value=[]))
|
||||
)
|
||||
create_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 1}))
|
||||
apply_resp = MagicMock(status_code=201)
|
||||
comment_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 5}))
|
||||
@@ -464,6 +474,7 @@ class TestDispatchTask:
|
||||
# wait_for_completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForCompletion:
|
||||
async def test_returns_completed_when_issue_closed(self):
|
||||
closed_resp = MagicMock(
|
||||
|
||||
@@ -25,7 +25,6 @@ from timmy.backlog_triage import (
|
||||
score_issue,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ Refs: #1073
|
||||
"""
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -79,7 +78,9 @@ def test_get_memory_info_handles_subprocess_failure(monitor):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_memory_ok(monitor):
|
||||
with patch.object(monitor, "_get_memory_info", return_value={"free_gb": 20.0, "total_gb": 64.0}):
|
||||
with patch.object(
|
||||
monitor, "_get_memory_info", return_value={"free_gb": 20.0, "total_gb": 64.0}
|
||||
):
|
||||
result = await monitor._check_memory()
|
||||
|
||||
assert result.name == "memory"
|
||||
@@ -126,7 +127,7 @@ async def test_check_memory_exception_returns_unknown(monitor):
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_disk_ok(monitor):
|
||||
usage = MagicMock()
|
||||
usage.free = 100 * (1024**3) # 100 GB
|
||||
usage.free = 100 * (1024**3) # 100 GB
|
||||
usage.total = 500 * (1024**3) # 500 GB
|
||||
usage.used = 400 * (1024**3)
|
||||
|
||||
@@ -140,7 +141,7 @@ async def test_check_disk_ok(monitor):
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_disk_low_triggers_cleanup(monitor):
|
||||
usage = MagicMock()
|
||||
usage.free = 5 * (1024**3) # 5 GB — below threshold
|
||||
usage.free = 5 * (1024**3) # 5 GB — below threshold
|
||||
usage.total = 500 * (1024**3)
|
||||
usage.used = 495 * (1024**3)
|
||||
|
||||
@@ -176,12 +177,8 @@ async def test_check_disk_critical_when_cleanup_fails(monitor):
|
||||
|
||||
|
||||
def test_get_ollama_status_reachable(monitor):
|
||||
tags_body = json.dumps({
|
||||
"models": [{"name": "qwen3:30b"}, {"name": "llama3.1:8b"}]
|
||||
}).encode()
|
||||
ps_body = json.dumps({
|
||||
"models": [{"name": "qwen3:30b", "size": 1000}]
|
||||
}).encode()
|
||||
tags_body = json.dumps({"models": [{"name": "qwen3:30b"}, {"name": "llama3.1:8b"}]}).encode()
|
||||
ps_body = json.dumps({"models": [{"name": "qwen3:30b", "size": 1000}]}).encode()
|
||||
|
||||
responses = [
|
||||
_FakeHTTPResponse(tags_body),
|
||||
|
||||
@@ -6,7 +6,6 @@ import pytest
|
||||
|
||||
from timmy.vassal.agent_health import AgentHealthReport, AgentStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentStatus
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,9 +48,7 @@ def test_report_any_stuck():
|
||||
|
||||
|
||||
def test_report_all_idle():
|
||||
report = AgentHealthReport(
|
||||
agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")]
|
||||
)
|
||||
report = AgentHealthReport(agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")])
|
||||
assert report.all_idle is True
|
||||
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import pytest
|
||||
|
||||
from timmy.vassal.backlog import (
|
||||
AgentTarget,
|
||||
TriagedIssue,
|
||||
_choose_agent,
|
||||
_extract_labels,
|
||||
_score_priority,
|
||||
triage_issues,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_labels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,6 @@ from timmy.vassal.house_health import (
|
||||
_probe_disk,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,7 +6,6 @@ import pytest
|
||||
|
||||
from timmy.vassal.orchestration_loop import VassalCycleRecord, VassalOrchestrator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VassalCycleRecord
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +133,6 @@ def test_orchestrator_stop_when_not_running():
|
||||
|
||||
|
||||
def test_module_singleton_exists():
|
||||
from timmy.vassal import vassal_orchestrator, VassalOrchestrator
|
||||
from timmy.vassal import VassalOrchestrator, vassal_orchestrator
|
||||
|
||||
assert isinstance(vassal_orchestrator, VassalOrchestrator)
|
||||
|
||||
Reference in New Issue
Block a user