From 997fde75c3f09deed63ddf973122e5873bd31727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 07:51:51 +0000 Subject: [PATCH 1/4] Initial plan From 36dc2908eeaffa014911f640b5653d177dcb9bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 07:56:42 +0000 Subject: [PATCH 2/4] Fix workflow and linting issues - Update CI workflow to only run on pushes to main - Add pip caching to CI workflow Python job - Fix all auto-fixable linting issues (imports, type annotations) - Add logging to auth.py instead of print - Configure per-file ignores for T201 (print) in CLI tools - All linting checks now pass - Security tests pass Co-authored-by: heidi-dang <35790+heidi-dang@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- ollama_detection.py | 8 +++----- parallel_orchestrator.py | 11 ++++++----- pyproject.toml | 7 +++++++ security.py | 21 ++++++++++----------- server/routers/auth.py | 4 +++- server/services/expand_chat_session.py | 14 +++++++------- test_health.py | 1 + 8 files changed, 39 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e757f53c..a33e9fac 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: Push CI on: push: branches: [main] - pull_request: - branches: [main] jobs: repo-guards: @@ -37,6 +35,8 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt - name: Install dependencies run: pip install -r requirements.txt - name: Lint with ruff diff --git a/ollama_detection.py b/ollama_detection.py index 3cd4dba5..9c362e9f 100644 --- a/ollama_detection.py +++ b/ollama_detection.py @@ -8,17 +8,15 @@ This is useful when Ollama is auto-started by the system on different ports. """ -import httpx import os -import socket -from typing import Optional +import httpx # Common Ollama ports (default is 11434, but can run on others) COMMON_OLLAMA_PORTS = [11434, 36199, 11435, 8000, 8080, 5000] -def detect_ollama_url() -> Optional[str]: +def detect_ollama_url() -> str | None: """ Automatically detect running Ollama instance. @@ -81,7 +79,7 @@ def _test_ollama_connection(url: str, timeout: float = 1.0) -> bool: return False -def get_ollama_url_sync() -> Optional[str]: +def get_ollama_url_sync() -> str | None: """ Synchronous Ollama detection. Use this in non-async contexts. diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py index 574cbd2c..d2fd1c17 100644 --- a/parallel_orchestrator.py +++ b/parallel_orchestrator.py @@ -23,9 +23,10 @@ import subprocess import sys import threading -from datetime import datetime, timezone +from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path -from typing import Callable, Literal +from typing import Literal from api.database import Feature, create_database from api.dependency_resolver import are_dependencies_satisfied, compute_scheduling_scores @@ -688,7 +689,7 @@ async def stream_output(): await asyncio.wait_for(stream_output(), timeout=INITIALIZER_TIMEOUT) - except asyncio.TimeoutError: + except TimeoutError: print(f"ERROR: Initializer timed out after {INITIALIZER_TIMEOUT // 60} minutes", flush=True) debug_log.log("INIT", "TIMEOUT - Initializer exceeded time limit", timeout_minutes=INITIALIZER_TIMEOUT // 60) @@ -777,7 +778,7 @@ async def _wait_for_agent_completion(self, timeout: float = POLL_INTERVAL): # Event was set - an agent completed. Clear it for the next wait cycle. self._agent_completed_event.clear() debug_log.log("EVENT", "Woke up immediately - agent completed") - except asyncio.TimeoutError: + except TimeoutError: # Timeout reached without agent completion - this is normal, just check anyway pass @@ -923,7 +924,7 @@ async def run_loop(self): self._event_loop = asyncio.get_running_loop() # Track session start for regression testing (UTC for consistency with last_tested_at) - self.session_start_time = datetime.now(timezone.utc) + self.session_start_time = datetime.now(UTC) # Start debug logging session FIRST (clears previous logs) # Must happen before any debug_log.log() calls diff --git a/pyproject.toml b/pyproject.toml index d92c3cdb..0b050c89 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,13 @@ ignore = [ "BLE001", # Do not catch blind exception (common in async code) ] +[tool.ruff.lint.per-file-ignores] +# CLI tools and test files can use print() for user output +"start*.py" = ["T201"] +"test_*.py" = ["T201"] +"client.py" = ["T201"] # User-facing configuration messages +"parallel_orchestrator.py" = ["T201"] # User-facing orchestrator output + [tool.ruff.lint.isort] known-first-party = ["server", "api", "mcp_server"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] diff --git a/security.py b/security.py index 024ad042..44faf24d 100644 --- a/security.py +++ b/security.py @@ -11,7 +11,6 @@ import re import shlex from pathlib import Path -from typing import Optional import yaml @@ -291,7 +290,7 @@ def extract_commands(command_string: str) -> list[str]: def validate_pkill_command( command_string: str, - extra_processes: Optional[set[str]] = None + extra_processes: set[str] | None = None ) -> tuple[bool, str]: """ Validate pkill commands - only allow killing dev-related processes. @@ -482,7 +481,7 @@ def get_org_config_path() -> Path: return Path.home() / ".autocoder" / "config.yaml" -def load_org_config() -> Optional[dict]: +def load_org_config() -> dict | None: """ Load organization-level config from ~/.autocoder/config.yaml. @@ -495,7 +494,7 @@ def load_org_config() -> Optional[dict]: return None try: - with open(config_path, "r", encoding="utf-8") as f: + with open(config_path, encoding="utf-8") as f: config = yaml.safe_load(f) if not config: @@ -565,12 +564,12 @@ def load_org_config() -> Optional[dict]: except yaml.YAMLError as e: logger.warning(f"Failed to parse org config at {config_path}: {e}") return None - except (IOError, OSError) as e: + except OSError as e: logger.warning(f"Failed to read org config at {config_path}: {e}") return None -def load_project_commands(project_dir: Path) -> Optional[dict]: +def load_project_commands(project_dir: Path) -> dict | None: """ Load allowed commands from project-specific YAML config. @@ -586,7 +585,7 @@ def load_project_commands(project_dir: Path) -> Optional[dict]: return None try: - with open(config_path, "r", encoding="utf-8") as f: + with open(config_path, encoding="utf-8") as f: config = yaml.safe_load(f) if not config: @@ -650,7 +649,7 @@ def load_project_commands(project_dir: Path) -> Optional[dict]: except yaml.YAMLError as e: logger.warning(f"Failed to parse project config at {config_path}: {e}") return None - except (IOError, OSError) as e: + except OSError as e: logger.warning(f"Failed to read project config at {config_path}: {e}") return None @@ -702,7 +701,7 @@ def validate_project_command(cmd_config: dict) -> tuple[bool, str]: return True, "" -def get_effective_commands(project_dir: Optional[Path]) -> tuple[set[str], set[str]]: +def get_effective_commands(project_dir: Path | None) -> tuple[set[str], set[str]]: """ Get effective allowed and blocked commands after hierarchy resolution. @@ -753,7 +752,7 @@ def get_effective_commands(project_dir: Optional[Path]) -> tuple[set[str], set[s return allowed, blocked -def get_project_allowed_commands(project_dir: Optional[Path]) -> set[str]: +def get_project_allowed_commands(project_dir: Path | None) -> set[str]: """ Get the set of allowed commands for a project. @@ -769,7 +768,7 @@ def get_project_allowed_commands(project_dir: Optional[Path]) -> set[str]: return allowed -def get_effective_pkill_processes(project_dir: Optional[Path]) -> set[str]: +def get_effective_pkill_processes(project_dir: Path | None) -> set[str]: """ Get effective pkill process names after hierarchy resolution. diff --git a/server/routers/auth.py b/server/routers/auth.py index 5097ae8e..16b1b6e3 100644 --- a/server/routers/auth.py +++ b/server/routers/auth.py @@ -16,6 +16,7 @@ import hashlib import hmac import json +import logging import os import secrets from datetime import UTC, datetime, timedelta @@ -30,6 +31,7 @@ import registry router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger(__name__) ACCESS_COOKIE_NAME = "access_token" @@ -780,7 +782,7 @@ async def logout(request: Request): _revoke_refresh_token(token) except Exception as e: # Log but don't fail - token revocation failure shouldn't prevent logout - print(f"WARNING: Failed to revoke refresh token on logout: {e}") + logger.warning("Failed to revoke refresh token on logout: %s", e) response = Response(content=json.dumps({"ok": True}), media_type="application/json") _clear_auth_cookies(response) diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 58dd50d5..267c7616 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -14,9 +14,9 @@ import sys import threading import uuid +from collections.abc import AsyncGenerator from datetime import datetime from pathlib import Path -from typing import AsyncGenerator, Optional from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv @@ -82,15 +82,15 @@ def __init__(self, project_name: str, project_dir: Path): """ self.project_name = project_name self.project_dir = project_dir - self.client: Optional[ClaudeSDKClient] = None + self.client: ClaudeSDKClient | None = None self.messages: list[dict] = [] self.complete: bool = False self.created_at = datetime.now() - self._conversation_id: Optional[str] = None + self._conversation_id: str | None = None self._client_entered: bool = False self.features_created: int = 0 self.created_feature_ids: list[int] = [] - self._settings_file: Optional[Path] = None + self._settings_file: Path | None = None self._query_lock = asyncio.Lock() async def close(self) -> None: @@ -353,7 +353,7 @@ def get_messages(self) -> list[dict]: _expand_sessions_lock = threading.Lock() -def get_expand_session(project_name: str) -> Optional[ExpandChatSession]: +def get_expand_session(project_name: str) -> ExpandChatSession | None: """Get an existing expansion session for a project.""" with _expand_sessions_lock: return _expand_sessions.get(project_name) @@ -361,7 +361,7 @@ def get_expand_session(project_name: str) -> Optional[ExpandChatSession]: async def create_expand_session(project_name: str, project_dir: Path) -> ExpandChatSession: """Create a new expansion session for a project, closing any existing one.""" - old_session: Optional[ExpandChatSession] = None + old_session: ExpandChatSession | None = None with _expand_sessions_lock: old_session = _expand_sessions.pop(project_name, None) @@ -379,7 +379,7 @@ async def create_expand_session(project_name: str, project_dir: Path) -> ExpandC async def remove_expand_session(project_name: str) -> None: """Remove and close an expansion session.""" - session: Optional[ExpandChatSession] = None + session: ExpandChatSession | None = None with _expand_sessions_lock: session = _expand_sessions.pop(project_name, None) diff --git a/test_health.py b/test_health.py index a3867fba..43719525 100755 --- a/test_health.py +++ b/test_health.py @@ -3,6 +3,7 @@ import pytest from fastapi.testclient import TestClient + @pytest.fixture() def client(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("AUTOCODER_ALLOW_REMOTE", "1") From 4a11c87c5a7d28e1d275327defab938511259554 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 07:59:07 +0000 Subject: [PATCH 3/4] Fix resource leaks and improve error handling - Fix database connection leaks in progress.py by using context managers - Add logging to all exception handlers in progress.py - Properly close SQLite connections even when exceptions occur - Add detailed error logging for better debugging Co-authored-by: heidi-dang <35790+heidi-dang@users.noreply.github.com> --- progress.py | 99 +++++++++++++++++++++++++++-------------------------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/progress.py b/progress.py index eaa15372..2033f8c7 100755 --- a/progress.py +++ b/progress.py @@ -7,12 +7,14 @@ """ import json +import logging import os import sqlite3 import urllib.request from datetime import UTC, datetime from pathlib import Path +logger = logging.getLogger(__name__) WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL") PROGRESS_CACHE_FILE = ".progress_cache" @@ -44,14 +46,14 @@ def has_features(project_dir: Path) -> bool: return False try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM features") - count = cursor.fetchone()[0] - conn.close() - return count > 0 - except Exception: + with sqlite3.connect(db_file) as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM features") + count = cursor.fetchone()[0] + return count > 0 + except Exception as e: # Database exists but can't be read or has no features table + logger.debug("Failed to check features in %s: %s", db_file, e) return False @@ -70,37 +72,37 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: return 0, 0, 0 try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - # Single aggregate query instead of 3 separate COUNT queries - # Handle case where in_progress column doesn't exist yet (legacy DBs) - try: - cursor.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, - SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress - FROM features - """) - row = cursor.fetchone() - total = row[0] or 0 - passing = row[1] or 0 - in_progress = row[2] or 0 - except sqlite3.OperationalError: - # Fallback for databases without in_progress column - cursor.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing - FROM features - """) - row = cursor.fetchone() - total = row[0] or 0 - passing = row[1] or 0 - in_progress = 0 - conn.close() - return passing, in_progress, total - except Exception: + with sqlite3.connect(db_file) as conn: + cursor = conn.cursor() + # Single aggregate query instead of 3 separate COUNT queries + # Handle case where in_progress column doesn't exist yet (legacy DBs) + try: + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, + SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = row[2] or 0 + except sqlite3.OperationalError: + # Fallback for databases without in_progress column + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = 0 + return passing, in_progress, total + except Exception as e: + logger.error("Failed to count tests in %s: %s", db_file, e) return 0, 0, 0 @@ -119,13 +121,13 @@ def get_all_passing_features(project_dir: Path) -> list[dict]: return [] try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - cursor.execute("SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC") - features = [{"id": row[0], "category": row[1], "name": row[2]} for row in cursor.fetchall()] - conn.close() - return features - except Exception: + with sqlite3.connect(db_file) as conn: + cursor = conn.cursor() + cursor.execute("SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC") + features = [{"id": row[0], "category": row[1], "name": row[2]} for row in cursor.fetchall()] + return features + except Exception as e: + logger.error("Failed to get passing features from %s: %s", db_file, e) return [] @@ -144,7 +146,8 @@ def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None: cache_data = json.loads(cache_file.read_text()) previous = cache_data.get("count", 0) previous_passing_ids = set(cache_data.get("passing_ids", [])) - except Exception: + except Exception as e: + logger.debug("Failed to read progress cache: %s", e) previous = 0 # Only notify if progress increased @@ -191,8 +194,8 @@ def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None: headers={"Content-Type": "application/json"}, ) urllib.request.urlopen(req, timeout=5) - except Exception: - pass + except Exception as e: + logger.warning("Failed to send progress webhook: %s", e) # Update cache with count and passing IDs cache_file.write_text(json.dumps({"count": passing, "passing_ids": current_passing_ids})) From 15c889b5e570b1cb0ad2d53f1052655ec0f5feb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 08:02:41 +0000 Subject: [PATCH 4/4] Fix datetime timezone issues and improve error logging - Fix naive datetime usage in registry.py (use UTC) - Fix datetime initialization in parallel_orchestrator.py - Add comprehensive error logging to prompts.py - Prevent timezone-related bugs from mixing naive and aware datetimes Co-authored-by: heidi-dang <35790+heidi-dang@users.noreply.github.com> --- parallel_orchestrator.py | 6 +++--- prompts.py | 28 +++++++++++++++++----------- registry.py | 8 ++++---- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py index d2fd1c17..e9e29aba 100644 --- a/parallel_orchestrator.py +++ b/parallel_orchestrator.py @@ -54,12 +54,12 @@ def start_session(self): with self._lock: self._session_started = True with open(self.log_file, "w") as f: - f.write(f"=== Orchestrator Debug Log Started: {datetime.now().isoformat()} ===\n") + f.write(f"=== Orchestrator Debug Log Started: {datetime.now(UTC).isoformat()} ===\n") f.write(f"=== PID: {os.getpid()} ===\n\n") def log(self, category: str, message: str, **kwargs): """Write a timestamped log entry.""" - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + timestamp = datetime.now(UTC).strftime("%H:%M:%S.%f")[:-3] with self._lock: with open(self.log_file, "a") as f: f.write(f"[{timestamp}] [{category}] {message}\n") @@ -184,7 +184,7 @@ def __init__( self._failure_counts: dict[int, int] = {} # Session tracking for logging/debugging - self.session_start_time: datetime = None + self.session_start_time: datetime = datetime.now(UTC) # Event signaled when any agent completes, allowing the main loop to wake # immediately instead of waiting for the full POLL_INTERVAL timeout. diff --git a/prompts.py b/prompts.py index 673f9ad1..015a11dd 100755 --- a/prompts.py +++ b/prompts.py @@ -9,9 +9,12 @@ 2. Base template: .claude/templates/{name}.template.md """ +import logging import shutil from pathlib import Path +logger = logging.getLogger(__name__) + # Base templates location (generic templates) TEMPLATES_DIR = Path(__file__).parent / ".claude" / "templates" @@ -46,16 +49,16 @@ def load_prompt(name: str, project_dir: Path | None = None) -> str: if project_path.exists(): try: return project_path.read_text(encoding="utf-8") - except (OSError, PermissionError): - pass + except (OSError, PermissionError) as e: + logger.warning("Failed to read project-specific prompt %s: %s", project_path, e) # 2. Try base template template_path = TEMPLATES_DIR / f"{name}.template.md" if template_path.exists(): try: return template_path.read_text(encoding="utf-8") - except (OSError, PermissionError): - pass + except (OSError, PermissionError) as e: + logger.error("Failed to read base template %s: %s", template_path, e) raise FileNotFoundError( f"Prompt '{name}' not found in:\n" @@ -212,8 +215,8 @@ def scaffold_project_prompts(project_dir: Path) -> Path: try: shutil.copy(template_path, dest_path) copied_files.append(dest_name) - except (OSError, PermissionError): - pass + except (OSError, PermissionError) as e: + logger.warning("Failed to copy template %s to %s: %s", template_name, dest_path, e) # Copy allowed_commands.yaml template to .autocoder/ examples_dir = Path(__file__).parent / "examples" @@ -223,8 +226,8 @@ def scaffold_project_prompts(project_dir: Path) -> Path: try: shutil.copy(allowed_commands_template, allowed_commands_dest) copied_files.append(".autocoder/allowed_commands.yaml") - except (OSError, PermissionError): - pass + except (OSError, PermissionError) as e: + logger.warning("Failed to copy allowed_commands.yaml template: %s", e) if copied_files: pass @@ -257,7 +260,8 @@ def has_project_prompts(project_dir: Path) -> bool: try: content = legacy_spec.read_text(encoding="utf-8") return "" in content - except (OSError, PermissionError): + except (OSError, PermissionError) as e: + logger.debug("Failed to read legacy spec %s: %s", legacy_spec, e) return False return False @@ -265,7 +269,8 @@ def has_project_prompts(project_dir: Path) -> bool: try: content = app_spec.read_text(encoding="utf-8") return "" in content - except (OSError, PermissionError): + except (OSError, PermissionError) as e: + logger.debug("Failed to read app spec %s: %s", app_spec, e) return False @@ -294,5 +299,6 @@ def copy_spec_to_project(project_dir: Path) -> None: try: shutil.copy(project_spec, spec_dest) return - except (OSError, PermissionError): + except (OSError, PermissionError) as e: + logger.warning("Failed to copy spec to project root: %s", e) return diff --git a/registry.py b/registry.py index f84803e8..9410da5c 100644 --- a/registry.py +++ b/registry.py @@ -12,7 +12,7 @@ import threading import time from contextlib import contextmanager -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -258,7 +258,7 @@ def register_project(name: str, path: Path) -> None: project = Project( name=name, path=path.as_posix(), - created_at=datetime.now() + created_at=datetime.now(UTC) ) session.add(project) @@ -552,12 +552,12 @@ def set_setting(key: str, value: str) -> None: setting = session.query(Settings).filter(Settings.key == key).first() if setting: setting.value = value - setting.updated_at = datetime.now() + setting.updated_at = datetime.now(UTC) else: setting = Settings( key=key, value=value, - updated_at=datetime.now() + updated_at=datetime.now(UTC) ) session.add(setting)