1
0

Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
4e4206a91e fix: resolve 23 ruff lint errors
Fixes #1149
2026-03-23 14:51:57 -04:00
22 changed files with 116 additions and 124 deletions

View File

@@ -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 import router as chat_api_router
from dashboard.routes.chat_api_v1 import router as chat_api_v1_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.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.db_explorer import router as db_explorer_router
from dashboard.routes.discord import router as discord_router from dashboard.routes.discord import router as discord_router
from dashboard.routes.experiments import router as experiments_router from dashboard.routes.experiments import router as experiments_router
from dashboard.routes.grok import router as grok_router from dashboard.routes.grok import router as grok_router
from dashboard.routes.health import router as health_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.loop_qa import router as loop_qa_router
from dashboard.routes.memory import router as memory_router from dashboard.routes.memory import router as memory_router
from dashboard.routes.mobile import router as mobile_router from dashboard.routes.mobile import router as mobile_router

View File

@@ -41,6 +41,7 @@ def _save_voice_settings(data: dict) -> None:
except Exception as exc: except Exception as exc:
logger.warning("Failed to save voice settings: %s", exc) logger.warning("Failed to save voice settings: %s", exc)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice", tags=["voice"]) router = APIRouter(prefix="/voice", tags=["voice"])

View File

@@ -4,6 +4,6 @@ Monitors the local machine (Hermes/M3 Max) for memory pressure, disk usage,
Ollama model health, zombie processes, and network connectivity. 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"] __all__ = ["HermesMonitor", "HealthLevel", "HealthReport", "hermes_monitor"]

View File

@@ -19,11 +19,12 @@ import json
import logging import logging
import shutil import shutil
import subprocess import subprocess
import tempfile
import time import time
import urllib.request import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import StrEnum
from typing import Any from typing import Any
from config import settings from config import settings
@@ -31,7 +32,7 @@ from config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class HealthLevel(str, Enum): class HealthLevel(StrEnum):
"""Severity level for a health check result.""" """Severity level for a health check result."""
OK = "ok" OK = "ok"
@@ -194,8 +195,7 @@ class HermesMonitor:
name="memory", name="memory",
level=HealthLevel.CRITICAL, level=HealthLevel.CRITICAL,
message=( message=(
f"Critical: only {free_gb:.1f}GB free " f"Critical: only {free_gb:.1f}GB free (threshold: {memory_free_min_gb}GB)"
f"(threshold: {memory_free_min_gb}GB)"
), ),
details=details, details=details,
needs_human=True, needs_human=True,
@@ -302,8 +302,7 @@ class HermesMonitor:
name="disk", name="disk",
level=HealthLevel.CRITICAL, level=HealthLevel.CRITICAL,
message=( message=(
f"Critical: only {free_gb:.1f}GB free " f"Critical: only {free_gb:.1f}GB free (threshold: {disk_free_min_gb}GB)"
f"(threshold: {disk_free_min_gb}GB)"
), ),
details=details, details=details,
needs_human=True, needs_human=True,
@@ -335,7 +334,7 @@ class HermesMonitor:
cutoff = time.time() - 86400 # 24 hours ago cutoff = time.time() - 86400 # 24 hours ago
try: try:
tmp = Path("/tmp") tmp = Path(tempfile.gettempdir())
for item in tmp.iterdir(): for item in tmp.iterdir():
try: try:
stat = item.stat() stat = item.stat()
@@ -345,11 +344,7 @@ class HermesMonitor:
freed_bytes += stat.st_size freed_bytes += stat.st_size
item.unlink(missing_ok=True) item.unlink(missing_ok=True)
elif item.is_dir(): elif item.is_dir():
dir_size = sum( dir_size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file())
f.stat().st_size
for f in item.rglob("*")
if f.is_file()
)
freed_bytes += dir_size freed_bytes += dir_size
shutil.rmtree(str(item), ignore_errors=True) shutil.rmtree(str(item), ignore_errors=True)
except (PermissionError, OSError): except (PermissionError, OSError):
@@ -392,10 +387,7 @@ class HermesMonitor:
return CheckResult( return CheckResult(
name="ollama", name="ollama",
level=HealthLevel.OK, level=HealthLevel.OK,
message=( message=(f"Ollama OK — {len(models)} model(s) available, {len(loaded)} loaded"),
f"Ollama OK — {len(models)} model(s) available, "
f"{len(loaded)} loaded"
),
details={ details={
"reachable": True, "reachable": True,
"model_count": len(models), "model_count": len(models),

View File

@@ -135,7 +135,9 @@ class BannerlordObserver:
self._host = host or settings.gabs_host self._host = host or settings.gabs_host
self._port = port or settings.gabs_port self._port = port or settings.gabs_port
self._timeout = timeout if timeout is not None else settings.gabs_timeout 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._journal_path = Path(journal_path) if journal_path else _get_journal_path()
self._entry_count = 0 self._entry_count = 0
self._days_observed: set[str] = set() self._days_observed: set[str] = set()

View File

@@ -196,9 +196,7 @@ class EmotionalStateTracker:
"intensity_label": _intensity_label(self.state.intensity), "intensity_label": _intensity_label(self.state.intensity),
"previous_emotion": self.state.previous_emotion, "previous_emotion": self.state.previous_emotion,
"trigger_event": self.state.trigger_event, "trigger_event": self.state.trigger_event,
"prompt_modifier": EMOTION_PROMPT_MODIFIERS.get( "prompt_modifier": EMOTION_PROMPT_MODIFIERS.get(self.state.current_emotion, ""),
self.state.current_emotion, ""
),
} }
def get_prompt_modifier(self) -> str: def get_prompt_modifier(self) -> str:

View File

@@ -36,7 +36,7 @@ import asyncio
import logging import logging
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
@@ -70,7 +70,9 @@ _LOOP_TAG = "loop-generated"
# Regex patterns for scoring # Regex patterns for scoring
_TAG_RE = re.compile(r"\[([^\]]+)\]") _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) _FUNC_RE = re.compile(r"(?:def |class |function |method |`\w+\(\)`)", re.IGNORECASE)
_ACCEPT_RE = re.compile( _ACCEPT_RE = re.compile(
r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria" r"(?:should|must|expect|verify|assert|test.?case|acceptance|criteria"
@@ -451,9 +453,7 @@ async def add_label(
# Apply to the issue # Apply to the issue
apply_url = _repo_url(f"issues/{issue_number}/labels") apply_url = _repo_url(f"issues/{issue_number}/labels")
apply_resp = await client.post( apply_resp = await client.post(apply_url, headers=headers, json={"labels": [label_id]})
apply_url, headers=headers, json={"labels": [label_id]}
)
return apply_resp.status_code in (200, 201) return apply_resp.status_code in (200, 201)
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc: except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
@@ -692,7 +692,9 @@ class BacklogTriageLoop:
# 1. Fetch # 1. Fetch
raw_issues = await fetch_open_issues(client) raw_issues = await fetch_open_issues(client)
result.total_open = len(raw_issues) 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 # 2. Score
scored = [score_issue(i) for i in raw_issues] scored = [score_issue(i) for i in raw_issues]

View File

@@ -37,7 +37,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import StrEnum
from typing import Any from typing import Any
from config import settings from config import settings
@@ -48,7 +48,8 @@ logger = logging.getLogger(__name__)
# Enumerations # Enumerations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class AgentType(str, Enum):
class AgentType(StrEnum):
"""Known agents in the swarm.""" """Known agents in the swarm."""
CLAUDE_CODE = "claude_code" CLAUDE_CODE = "claude_code"
@@ -57,7 +58,7 @@ class AgentType(str, Enum):
TIMMY = "timmy" TIMMY = "timmy"
class TaskType(str, Enum): class TaskType(StrEnum):
"""Categories of engineering work.""" """Categories of engineering work."""
# Claude Code strengths # Claude Code strengths
@@ -83,7 +84,7 @@ class TaskType(str, Enum):
ORCHESTRATION = "orchestration" ORCHESTRATION = "orchestration"
class DispatchStatus(str, Enum): class DispatchStatus(StrEnum):
"""Lifecycle state of a dispatched task.""" """Lifecycle state of a dispatched task."""
PENDING = "pending" PENDING = "pending"
@@ -99,6 +100,7 @@ class DispatchStatus(str, Enum):
# Agent registry # Agent registry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class AgentSpec: class AgentSpec:
"""Capabilities and limits for a single agent.""" """Capabilities and limits for a single agent."""
@@ -106,9 +108,9 @@ class AgentSpec:
name: AgentType name: AgentType
display_name: str display_name: str
strengths: frozenset[TaskType] 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 max_concurrent: int = 1
interface: str = "gitea" # "gitea" | "api" | "local" interface: str = "gitea" # "gitea" | "api" | "local"
api_endpoint: str | None = None # for interface="api" api_endpoint: str | None = None # for interface="api"
@@ -197,6 +199,7 @@ _TASK_ROUTING: dict[TaskType, AgentType] = {
# Dispatch result # Dispatch result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class DispatchResult: class DispatchResult:
"""Outcome of a dispatch call.""" """Outcome of a dispatch call."""
@@ -220,6 +223,7 @@ class DispatchResult:
# Routing logic # Routing logic
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def select_agent(task_type: TaskType) -> AgentType: def select_agent(task_type: TaskType) -> AgentType:
"""Return the best agent for *task_type* based on the routing table. """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() text = (title + " " + description).lower()
_SIGNALS: list[tuple[TaskType, frozenset[str]]] = [ _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.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.ANALYSIS, frozenset({"analysis", "profil", "trace", "metric", "performance"})),
(TaskType.TRIAGE, frozenset({"triage", "classify", "prioritise", "prioritize"})), (TaskType.TRIAGE, frozenset({"triage", "classify", "prioritise", "prioritize"})),
(TaskType.PLANNING, frozenset({"plan", "roadmap", "milestone", "epic", "spike"})), (TaskType.PLANNING, frozenset({"plan", "roadmap", "milestone", "epic", "spike"})),
@@ -273,6 +289,7 @@ def infer_task_type(title: str, description: str = "") -> TaskType:
# Gitea helpers # Gitea helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _post_gitea_comment( async def _post_gitea_comment(
client: Any, client: Any,
base_url: str, base_url: str,
@@ -405,6 +422,7 @@ async def _poll_issue_completion(
# Core dispatch functions # Core dispatch functions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _dispatch_via_gitea( async def _dispatch_via_gitea(
agent: AgentType, agent: AgentType,
issue_number: int, issue_number: int,
@@ -479,7 +497,11 @@ async def _dispatch_via_gitea(
) )
# 2. Post assignment comment # 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 = ( comment_body = (
f"## Assigned to {spec.display_name}\n\n" f"## Assigned to {spec.display_name}\n\n"
f"**Task type:** `{task_type.value}`\n\n" f"**Task type:** `{task_type.value}`\n\n"
@@ -616,9 +638,7 @@ async def _dispatch_local(
assumed to succeed at dispatch time). assumed to succeed at dispatch time).
""" """
task_type = infer_task_type(title, description) task_type = infer_task_type(title, description)
logger.info( logger.info("Timmy handling task locally: %r (issue #%s)", title[:60], issue_number)
"Timmy handling task locally: %r (issue #%s)", title[:60], issue_number
)
return DispatchResult( return DispatchResult(
task_type=task_type, task_type=task_type,
agent=AgentType.TIMMY, agent=AgentType.TIMMY,
@@ -632,6 +652,7 @@ async def _dispatch_local(
# Public entry point # Public entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def dispatch_task( async def dispatch_task(
title: str, title: str,
description: str = "", description: str = "",
@@ -769,9 +790,7 @@ async def _log_escalation(
f"---\n*Timmy agent dispatcher.*" f"---\n*Timmy agent dispatcher.*"
) )
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
await _post_gitea_comment( await _post_gitea_comment(client, base_url, repo, headers, issue_number, body)
client, base_url, repo, headers, issue_number, body
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to post escalation comment: %s", exc) logger.warning("Failed to post escalation comment: %s", exc)
@@ -780,6 +799,7 @@ async def _log_escalation(
# Monitoring helper # Monitoring helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def wait_for_completion( async def wait_for_completion(
issue_number: int, issue_number: int,
poll_interval: int = 60, poll_interval: int = 60,

View File

@@ -418,9 +418,7 @@ class MCPBridge:
return f"Error executing {name}: {exc}" return f"Error executing {name}: {exc}"
@staticmethod @staticmethod
def _build_initial_messages( def _build_initial_messages(prompt: str, system_prompt: str | None) -> list[dict]:
prompt: str, system_prompt: str | None
) -> list[dict]:
"""Build the initial message list for a run.""" """Build the initial message list for a run."""
messages: list[dict] = [] messages: list[dict] = []
if system_prompt: if system_prompt:
@@ -512,9 +510,7 @@ class MCPBridge:
error_msg = "" error_msg = ""
try: try:
content, tool_calls_made, rounds, error_msg = await self._run_tool_loop( content, tool_calls_made, rounds, error_msg = await self._run_tool_loop(messages, tools)
messages, tools
)
except httpx.ConnectError as exc: except httpx.ConnectError as exc:
logger.warning("Ollama connection failed: %s", exc) logger.warning("Ollama connection failed: %s", exc)
error_msg = f"Ollama connection failed: {exc}" error_msg = f"Ollama connection failed: {exc}"

View File

@@ -47,13 +47,11 @@ _DEFAULT_IDLE_THRESHOLD = 30
class AgentStatus: class AgentStatus:
"""Health snapshot for one agent at a point in time.""" """Health snapshot for one agent at a point in time."""
agent: str # "claude" | "kimi" | "timmy" agent: str # "claude" | "kimi" | "timmy"
is_idle: bool = True is_idle: bool = True
active_issue_numbers: list[int] = field(default_factory=list) active_issue_numbers: list[int] = field(default_factory=list)
stuck_issue_numbers: list[int] = field(default_factory=list) stuck_issue_numbers: list[int] = field(default_factory=list)
checked_at: str = field( checked_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
default_factory=lambda: datetime.now(UTC).isoformat()
)
@property @property
def is_stuck(self) -> bool: def is_stuck(self) -> bool:
@@ -69,9 +67,7 @@ class AgentHealthReport:
"""Combined health report for all monitored agents.""" """Combined health report for all monitored agents."""
agents: list[AgentStatus] = field(default_factory=list) agents: list[AgentStatus] = field(default_factory=list)
generated_at: str = field( generated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
default_factory=lambda: datetime.now(UTC).isoformat()
)
@property @property
def any_stuck(self) -> bool: def any_stuck(self) -> bool:
@@ -193,18 +189,14 @@ async def check_agent_health(
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
issues = await _fetch_labeled_issues( issues = await _fetch_labeled_issues(client, base_url, headers, repo, label)
client, base_url, headers, repo, label
)
for issue in issues: for issue in issues:
num = issue.get("number", 0) num = issue.get("number", 0)
status.active_issue_numbers.append(num) status.active_issue_numbers.append(num)
# Check last activity # Check last activity
last_activity = await _last_comment_time( last_activity = await _last_comment_time(client, base_url, headers, repo, num)
client, base_url, headers, repo, num
)
if last_activity is None: if last_activity is None:
last_activity = await _issue_created_time(issue) last_activity = await _issue_created_time(issue)

View File

@@ -91,9 +91,9 @@ _PRIORITY_LABEL_SCORES: dict[str, int] = {
class AgentTarget(StrEnum): class AgentTarget(StrEnum):
"""Which agent should handle this issue.""" """Which agent should handle this issue."""
TIMMY = "timmy" # Timmy handles locally (self) TIMMY = "timmy" # Timmy handles locally (self)
CLAUDE = "claude" # Dispatch to Claude Code CLAUDE = "claude" # Dispatch to Claude Code
KIMI = "kimi" # Dispatch to Kimi Code KIMI = "kimi" # Dispatch to Kimi Code
@dataclass @dataclass
@@ -172,9 +172,7 @@ def triage_issues(raw_issues: list[dict[str, Any]]) -> list[TriagedIssue]:
title = issue.get("title", "") title = issue.get("title", "")
body = issue.get("body") or "" body = issue.get("body") or ""
labels = _extract_labels(issue) labels = _extract_labels(issue)
assignees = [ assignees = [a.get("login", "") for a in issue.get("assignees") or []]
a.get("login", "") for a in issue.get("assignees") or []
]
url = issue.get("html_url", "") url = issue.get("html_url", "")
priority = _score_priority(labels, assignees) priority = _score_priority(labels, assignees)
@@ -252,9 +250,7 @@ async def fetch_open_issues(
params=params, params=params,
) )
if resp.status_code != 200: if resp.status_code != 200:
logger.warning( logger.warning("fetch_open_issues: Gitea returned %s", resp.status_code)
"fetch_open_issues: Gitea returned %s", resp.status_code
)
return [] return []
issues = resp.json() issues = resp.json()

View File

@@ -34,7 +34,7 @@ _LABEL_MAP: dict[AgentTarget, str] = {
_LABEL_COLORS: dict[str, str] = { _LABEL_COLORS: dict[str, str] = {
"claude-ready": "#8b6f47", # warm brown "claude-ready": "#8b6f47", # warm brown
"kimi-ready": "#006b75", # dark teal "kimi-ready": "#006b75", # dark teal
"timmy-ready": "#0075ca", # blue "timmy-ready": "#0075ca", # blue
} }
@@ -52,9 +52,7 @@ class DispatchRecord:
issue_title: str issue_title: str
agent: AgentTarget agent: AgentTarget
rationale: str rationale: str
dispatched_at: str = field( dispatched_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
default_factory=lambda: datetime.now(UTC).isoformat()
)
label_applied: bool = False label_applied: bool = False
comment_posted: bool = False comment_posted: bool = False
@@ -170,9 +168,7 @@ async def dispatch_issue(issue: TriagedIssue) -> DispatchRecord:
try: try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
label_id = await _get_or_create_label( label_id = await _get_or_create_label(client, base_url, headers, repo, label_name)
client, base_url, headers, repo, label_name
)
# Apply label # Apply label
if label_id is not None: if label_id is not None:

View File

@@ -22,9 +22,9 @@ logger = logging.getLogger(__name__)
# Thresholds # Thresholds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_WARN_DISK_PCT = 85.0 # warn when disk is more than 85% full _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_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_CPU_PCT = 95.0 # warn when CPU is above 95% sustained
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -63,9 +63,7 @@ class SystemSnapshot:
memory: MemoryUsage = field(default_factory=MemoryUsage) memory: MemoryUsage = field(default_factory=MemoryUsage)
ollama: OllamaHealth = field(default_factory=OllamaHealth) ollama: OllamaHealth = field(default_factory=OllamaHealth)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
taken_at: str = field( taken_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
default_factory=lambda: datetime.now(UTC).isoformat()
)
@property @property
def healthy(self) -> bool: def healthy(self) -> bool:
@@ -117,8 +115,8 @@ def _probe_memory() -> MemoryUsage:
def _probe_ollama_sync(ollama_url: str) -> OllamaHealth: def _probe_ollama_sync(ollama_url: str) -> OllamaHealth:
"""Synchronous Ollama health probe — run in a thread.""" """Synchronous Ollama health probe — run in a thread."""
try: try:
import urllib.request
import json import json
import urllib.request
url = ollama_url.rstrip("/") + "/api/tags" url = ollama_url.rstrip("/") + "/api/tags"
with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 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: if disk.percent_used >= _WARN_DISK_PCT:
warnings.append( warnings.append(
f"Disk {disk.path}: {disk.percent_used:.0f}% used " f"Disk {disk.path}: {disk.percent_used:.0f}% used ({disk.free_gb:.1f} GB free)"
f"({disk.free_gb:.1f} GB free)"
) )
if memory.percent_used >= _WARN_MEM_PCT: if memory.percent_used >= _WARN_MEM_PCT:
warnings.append( warnings.append(
f"Memory: {memory.percent_used:.0f}% used " f"Memory: {memory.percent_used:.0f}% used ({memory.available_gb:.1f} GB available)"
f"({memory.available_gb:.1f} GB available)"
) )
if not ollama.reachable: if not ollama.reachable:
@@ -216,7 +212,5 @@ async def cleanup_stale_files(
errors.append(str(exc)) errors.append(str(exc))
await asyncio.to_thread(_cleanup) await asyncio.to_thread(_cleanup)
logger.info( logger.info("cleanup_stale_files: deleted %d files, %d errors", deleted, len(errors))
"cleanup_stale_files: deleted %d files, %d errors", deleted, len(errors)
)
return {"deleted_count": deleted, "errors": errors} return {"deleted_count": deleted, "errors": errors}

View File

@@ -10,14 +10,12 @@ from __future__ import annotations
import json import json
import socket import socket
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from integrations.bannerlord.gabs_client import GabsClient, GabsError from integrations.bannerlord.gabs_client import GabsClient, GabsError
# ── GabsClient unit tests ───────────────────────────────────────────────────── # ── GabsClient unit tests ─────────────────────────────────────────────────────
@@ -236,7 +234,13 @@ class TestBannerlordObserver:
snapshot = { snapshot = {
"game_state": {"day": 7, "season": "winter", "campaign_phase": "early"}, "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}, "player_party": {"size": 25, "morale": 80, "food_days_left": 5},
"kingdoms": [{"name": "Vlandia", "ruler": "Derthert", "military_strength": 5000}], "kingdoms": [{"name": "Vlandia", "ruler": "Derthert", "military_strength": 5000}],
} }

View File

@@ -1,7 +1,6 @@
"""Tests for agent emotional state simulation (src/timmy/agents/emotional_state.py).""" """Tests for agent emotional state simulation (src/timmy/agents/emotional_state.py)."""
import time import time
from unittest.mock import patch
from timmy.agents.emotional_state import ( from timmy.agents.emotional_state import (
EMOTION_PROMPT_MODIFIERS, EMOTION_PROMPT_MODIFIERS,

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from timmy.dispatcher import ( from timmy.dispatcher import (
AGENT_REGISTRY, AGENT_REGISTRY,
AgentType, AgentType,
@@ -21,11 +19,11 @@ from timmy.dispatcher import (
wait_for_completion, wait_for_completion,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Agent registry # Agent registry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAgentRegistry: class TestAgentRegistry:
def test_all_agents_present(self): def test_all_agents_present(self):
for member in AgentType: for member in AgentType:
@@ -41,7 +39,7 @@ class TestAgentRegistry:
assert spec.gitea_label, f"{agent} is gitea interface but has no label" assert spec.gitea_label, f"{agent} is gitea interface but has no label"
def test_non_gitea_agents_have_no_labels(self): 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",): if spec.interface not in ("gitea",):
# api and local agents may have no label # api and local agents may have no label
assert spec.gitea_label is None or spec.interface == "gitea" assert spec.gitea_label is None or spec.interface == "gitea"
@@ -55,6 +53,7 @@ class TestAgentRegistry:
# select_agent # select_agent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSelectAgent: class TestSelectAgent:
def test_architecture_routes_to_claude(self): def test_architecture_routes_to_claude(self):
assert select_agent(TaskType.ARCHITECTURE) == AgentType.CLAUDE_CODE assert select_agent(TaskType.ARCHITECTURE) == AgentType.CLAUDE_CODE
@@ -85,6 +84,7 @@ class TestSelectAgent:
# infer_task_type # infer_task_type
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestInferTaskType: class TestInferTaskType:
def test_architecture_keyword(self): def test_architecture_keyword(self):
assert infer_task_type("Design the LLM router architecture") == TaskType.ARCHITECTURE assert infer_task_type("Design the LLM router architecture") == TaskType.ARCHITECTURE
@@ -119,6 +119,7 @@ class TestInferTaskType:
# DispatchResult # DispatchResult
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDispatchResult: class TestDispatchResult:
def test_success_when_assigned(self): def test_success_when_assigned(self):
r = DispatchResult( r = DispatchResult(
@@ -161,6 +162,7 @@ class TestDispatchResult:
# _dispatch_local # _dispatch_local
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDispatchLocal: class TestDispatchLocal:
async def test_returns_assigned(self): async def test_returns_assigned(self):
result = await _dispatch_local( result = await _dispatch_local(
@@ -190,6 +192,7 @@ class TestDispatchLocal:
# _dispatch_via_api # _dispatch_via_api
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDispatchViaApi: class TestDispatchViaApi:
async def test_no_endpoint_returns_failed(self): async def test_no_endpoint_returns_failed(self):
result = await _dispatch_via_api( result = await _dispatch_via_api(
@@ -304,7 +307,9 @@ class TestDispatchViaGitea:
assert result.status == DispatchStatus.ASSIGNED assert result.status == DispatchStatus.ASSIGNED
async def test_no_gitea_token_returns_failed(self): 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): with patch("timmy.dispatcher.settings", bad_settings):
result = await _dispatch_via_gitea( result = await _dispatch_via_gitea(
agent=AgentType.CLAUDE_CODE, agent=AgentType.CLAUDE_CODE,
@@ -317,7 +322,9 @@ class TestDispatchViaGitea:
assert "not configured" in (result.error or "").lower() assert "not configured" in (result.error or "").lower()
async def test_gitea_disabled_returns_failed(self): 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): with patch("timmy.dispatcher.settings", bad_settings):
result = await _dispatch_via_gitea( result = await _dispatch_via_gitea(
agent=AgentType.CLAUDE_CODE, agent=AgentType.CLAUDE_CODE,
@@ -368,6 +375,7 @@ class TestDispatchViaGitea:
# dispatch_task (integration-style) # dispatch_task (integration-style)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDispatchTask: class TestDispatchTask:
async def test_empty_title_returns_failed(self): async def test_empty_title_returns_failed(self):
result = await dispatch_task(title=" ") result = await dispatch_task(title=" ")
@@ -396,7 +404,9 @@ class TestDispatchTask:
client_mock = AsyncMock() client_mock = AsyncMock()
client_mock.__aenter__ = AsyncMock(return_value=client_mock) client_mock.__aenter__ = AsyncMock(return_value=client_mock)
client_mock.__aexit__ = AsyncMock(return_value=False) 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})) create_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 1}))
apply_resp = MagicMock(status_code=201) apply_resp = MagicMock(status_code=201)
comment_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 5})) comment_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 5}))
@@ -464,6 +474,7 @@ class TestDispatchTask:
# wait_for_completion # wait_for_completion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestWaitForCompletion: class TestWaitForCompletion:
async def test_returns_completed_when_issue_closed(self): async def test_returns_completed_when_issue_closed(self):
closed_resp = MagicMock( closed_resp = MagicMock(

View File

@@ -25,7 +25,6 @@ from timmy.backlog_triage import (
score_issue, score_issue,
) )
# ── Fixtures ───────────────────────────────────────────────────────────────── # ── Fixtures ─────────────────────────────────────────────────────────────────

View File

@@ -7,7 +7,6 @@ Refs: #1073
""" """
import json import json
from io import BytesIO
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -79,7 +78,9 @@ def test_get_memory_info_handles_subprocess_failure(monitor):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_memory_ok(monitor): 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() result = await monitor._check_memory()
assert result.name == "memory" assert result.name == "memory"
@@ -126,7 +127,7 @@ async def test_check_memory_exception_returns_unknown(monitor):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_disk_ok(monitor): async def test_check_disk_ok(monitor):
usage = MagicMock() usage = MagicMock()
usage.free = 100 * (1024**3) # 100 GB usage.free = 100 * (1024**3) # 100 GB
usage.total = 500 * (1024**3) # 500 GB usage.total = 500 * (1024**3) # 500 GB
usage.used = 400 * (1024**3) usage.used = 400 * (1024**3)
@@ -140,7 +141,7 @@ async def test_check_disk_ok(monitor):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_disk_low_triggers_cleanup(monitor): async def test_check_disk_low_triggers_cleanup(monitor):
usage = MagicMock() 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.total = 500 * (1024**3)
usage.used = 495 * (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): def test_get_ollama_status_reachable(monitor):
tags_body = json.dumps({ tags_body = json.dumps({"models": [{"name": "qwen3:30b"}, {"name": "llama3.1:8b"}]}).encode()
"models": [{"name": "qwen3:30b"}, {"name": "llama3.1:8b"}] ps_body = json.dumps({"models": [{"name": "qwen3:30b", "size": 1000}]}).encode()
}).encode()
ps_body = json.dumps({
"models": [{"name": "qwen3:30b", "size": 1000}]
}).encode()
responses = [ responses = [
_FakeHTTPResponse(tags_body), _FakeHTTPResponse(tags_body),

View File

@@ -6,7 +6,6 @@ import pytest
from timmy.vassal.agent_health import AgentHealthReport, AgentStatus from timmy.vassal.agent_health import AgentHealthReport, AgentStatus
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# AgentStatus # AgentStatus
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -49,9 +48,7 @@ def test_report_any_stuck():
def test_report_all_idle(): def test_report_all_idle():
report = AgentHealthReport( report = AgentHealthReport(agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")])
agents=[AgentStatus(agent="claude"), AgentStatus(agent="kimi")]
)
assert report.all_idle is True assert report.all_idle is True

View File

@@ -6,14 +6,12 @@ import pytest
from timmy.vassal.backlog import ( from timmy.vassal.backlog import (
AgentTarget, AgentTarget,
TriagedIssue,
_choose_agent, _choose_agent,
_extract_labels, _extract_labels,
_score_priority, _score_priority,
triage_issues, triage_issues,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _extract_labels # _extract_labels
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -12,7 +12,6 @@ from timmy.vassal.house_health import (
_probe_disk, _probe_disk,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Data model tests # Data model tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -6,7 +6,6 @@ import pytest
from timmy.vassal.orchestration_loop import VassalCycleRecord, VassalOrchestrator from timmy.vassal.orchestration_loop import VassalCycleRecord, VassalOrchestrator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# VassalCycleRecord # VassalCycleRecord
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -134,6 +133,6 @@ def test_orchestrator_stop_when_not_running():
def test_module_singleton_exists(): def test_module_singleton_exists():
from timmy.vassal import vassal_orchestrator, VassalOrchestrator from timmy.vassal import VassalOrchestrator, vassal_orchestrator
assert isinstance(vassal_orchestrator, VassalOrchestrator) assert isinstance(vassal_orchestrator, VassalOrchestrator)