diff --git a/codeframe/core/conductor.py b/codeframe/core/conductor.py index 1c8dfccd..8d867c30 100644 --- a/codeframe/core/conductor.py +++ b/codeframe/core/conductor.py @@ -8,6 +8,7 @@ import hashlib import json +import logging import re import subprocess import sys @@ -26,6 +27,8 @@ from codeframe.core.runtime import RunStatus, get_active_run, reset_blocked_run +logger = logging.getLogger(__name__) + # Tactical patterns that the supervisor should auto-resolve SUPERVISOR_TACTICAL_PATTERNS = [ # Virtual environment / package management @@ -1226,6 +1229,12 @@ def _execute_serial( Updates batch.results and batch.status as tasks complete. """ + # Start reconciliation thread for continuous state checking + from codeframe.core.config import load_environment_config + env_config = load_environment_config(workspace.repo_path) + interval = env_config.reconciliation_interval_seconds if env_config else 30 + reconcile_stop = _start_reconciliation_thread(workspace, batch, interval_seconds=interval) + completed_count = 0 failed_count = 0 blocked_count = 0 @@ -1359,6 +1368,9 @@ def _execute_serial( print_event=True, ) + # Stop reconciliation thread + reconcile_stop.set() + # Print summary print(f"\nBatch {batch.status.value.lower()}: {completed_count}/{total} tasks completed") if failed_count > 0: @@ -1394,6 +1406,12 @@ def _execute_parallel( else: print("All tasks are sequential (chain dependencies)") + # Start reconciliation thread for continuous state checking + from codeframe.core.config import load_environment_config as _load_env_config + _env_config_p = _load_env_config(workspace.repo_path) + _interval_p = _env_config_p.reconciliation_interval_seconds if _env_config_p else 30 + _reconcile_stop_p = _start_reconciliation_thread(workspace, batch, interval_seconds=_interval_p) + completed_count = 0 failed_count = 0 blocked_count = 0 @@ -1500,6 +1518,9 @@ def _execute_parallel( print_event=True, ) + # Stop reconciliation thread + _reconcile_stop_p.set() + # Print summary print(f"\nBatch {batch.status.value.lower()}: {completed_count}/{total} tasks completed") print(f" Execution: {plan.num_groups} groups (parallel strategy)") @@ -1509,6 +1530,71 @@ def _execute_parallel( print(f" Blocked: {blocked_count}") +def _start_reconciliation_thread( + workspace: Workspace, + batch: BatchRun, + interval_seconds: int = 30, + github_checker: Optional[Callable] = None, +) -> threading.Event: + """Start a daemon thread that periodically reconciles batch state. + + The thread checks all active tasks for external state changes every + ``interval_seconds`` and applies adjustments (skip completed tasks, + re-queue unblocked tasks). + + Returns a threading.Event that can be set to stop the thread. + """ + from codeframe.core.reconciliation import ReconciliationEngine + + stop_event = threading.Event() + engine = ReconciliationEngine(workspace, github_checker=github_checker) + + def _loop() -> None: + while not stop_event.wait(timeout=interval_seconds): + try: + # Get currently active task IDs from the batch + active_ids = [ + tid for tid in batch.task_ids + if batch.results.get(tid) is None + or batch.results.get(tid) == "RUNNING" + ] + if not active_ids: + continue + + result = engine.check_all_active(active_ids) + if result.changes_detected: + with _active_processes_lock: + procs = _active_processes.get(batch.id, {}) + engine.apply_changes(result, batch, procs) + + # Emit events for changes + for tid in result.tasks_skipped: + events.emit_for_workspace( + workspace, + events.EventType.RECONCILIATION_TASK_SKIPPED, + {"batch_id": batch.id, "task_id": tid}, + ) + for tid in result.tasks_requeued: + events.emit_for_workspace( + workspace, + events.EventType.RECONCILIATION_TASK_REQUEUED, + {"batch_id": batch.id, "task_id": tid}, + ) + if result.errors: + for err in result.errors: + events.emit_for_workspace( + workspace, + events.EventType.RECONCILIATION_ERROR, + {"batch_id": batch.id, "error": err}, + ) + except Exception as exc: + logger.warning("Reconciliation loop error: %s", exc) + + thread = threading.Thread(target=_loop, daemon=True, name=f"reconcile-{batch.id[:8]}") + thread.start() + return stop_event + + def _execute_single_task( workspace: Workspace, batch: BatchRun, diff --git a/codeframe/core/config.py b/codeframe/core/config.py index 3de6f3e5..4f731334 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -127,6 +127,9 @@ class EnvironmentConfig: # Workspace lifecycle hooks hooks: HooksConfig = dataclass_field(default_factory=HooksConfig) + # Reconciliation during batch execution + reconciliation_interval_seconds: int = 30 + # Execution engine engine: str = "react" diff --git a/codeframe/core/events.py b/codeframe/core/events.py index be672ee2..36941d2e 100644 --- a/codeframe/core/events.py +++ b/codeframe/core/events.py @@ -108,6 +108,12 @@ class EventType: HOOK_EXECUTED = "HOOK_EXECUTED" HOOK_FAILED = "HOOK_FAILED" + # Reconciliation events + RECONCILIATION_STARTED = "RECONCILIATION_STARTED" + RECONCILIATION_TASK_SKIPPED = "RECONCILIATION_TASK_SKIPPED" + RECONCILIATION_TASK_REQUEUED = "RECONCILIATION_TASK_REQUEUED" + RECONCILIATION_ERROR = "RECONCILIATION_ERROR" + @dataclass class Event: diff --git a/codeframe/core/reconciliation.py b/codeframe/core/reconciliation.py new file mode 100644 index 00000000..ee341419 --- /dev/null +++ b/codeframe/core/reconciliation.py @@ -0,0 +1,183 @@ +"""Continuous reconciliation engine for batch execution. + +Periodically checks if tasks have been externally modified (GitHub issue +closed, task manually completed, blocker resolved) and adjusts the running +batch accordingly. + +This module is headless (no FastAPI, no HTTP). It exposes a standalone +ReconciliationEngine that can be driven by a background thread in the +conductor. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Optional + +from codeframe.core import blockers, tasks +from codeframe.core.state_machine import TaskStatus + +if TYPE_CHECKING: + from codeframe.core.workspace import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class ExternalStateChange: + """A detected external change to a task's state.""" + + task_id: str + change_type: str # "completed", "closed", "blocker_resolved" + source: str # "manual", "github" + details: dict = field(default_factory=dict) + + +@dataclass +class ReconciliationResult: + """Accumulated result from a reconciliation check.""" + + changes_detected: list[ExternalStateChange] = field(default_factory=list) + tasks_skipped: list[str] = field(default_factory=list) + tasks_requeued: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +class ReconciliationEngine: + """Checks tasks for external state changes and applies adjustments. + + The engine is stateless per invocation — call check_all_active() to scan, + then apply_changes() to act on the results. + + Args: + workspace: The workspace to check tasks in. + github_checker: Optional callable(task_id, task) -> list[ExternalStateChange] + for GitHub issue state sync. If None, GitHub checks are skipped. + """ + + def __init__( + self, + workspace: Workspace, + *, + github_checker: Optional[Callable] = None, + ) -> None: + self._workspace = workspace + self._github_checker = github_checker + + def check_task(self, task_id: str) -> list[ExternalStateChange]: + """Check a single task for external state changes. + + Returns a list of detected changes (may be empty). + """ + task = tasks.get(self._workspace, task_id) + if task is None: + return [] + + changes: list[ExternalStateChange] = [] + + # Task was completed externally (e.g., manually marked DONE) + if task.status == TaskStatus.DONE: + changes.append(ExternalStateChange( + task_id=task_id, + change_type="completed", + source="manual", + details={"status": task.status.value}, + )) + + # Task is blocked but all blockers have been answered + elif task.status == TaskStatus.BLOCKED: + task_blockers = blockers.list_for_task(self._workspace, task_id) + if task_blockers and all( + b.status.value in ("ANSWERED", "RESOLVED") for b in task_blockers + ): + changes.append(ExternalStateChange( + task_id=task_id, + change_type="blocker_resolved", + source="manual", + details={"blockers_resolved": len(task_blockers)}, + )) + + # Check GitHub issue state if checker is available + if self._github_checker: + try: + gh_changes = self._github_checker(task_id, task) + changes.extend(gh_changes) + except Exception as exc: + logger.warning("GitHub check failed for task %s: %s", task_id, exc) + + return changes + + def check_all_active( + self, active_task_ids: list[str] + ) -> ReconciliationResult: + """Check all active tasks for external state changes. + + Individual task check failures are caught and logged — a single + failure never crashes the entire reconciliation pass. + """ + result = ReconciliationResult() + + for task_id in active_task_ids: + try: + changes = self.check_task(task_id) + result.changes_detected.extend(changes) + except Exception as exc: + error_msg = f"Reconciliation check failed for {task_id}: {exc}" + result.errors.append(error_msg) + logger.warning(error_msg) + + return result + + def apply_changes( + self, + result: ReconciliationResult, + batch: object, + active_processes: dict, + ) -> None: + """Apply detected changes to the batch and running processes. + + For completed/closed tasks: terminate the subprocess and skip. + For blocker_resolved tasks: mark for re-queue. + + All exceptions are caught and appended to result.errors. + """ + for change in result.changes_detected: + try: + if change.change_type in ("completed", "closed"): + # Terminate subprocess if running + proc = active_processes.get(change.task_id) + if proc is not None: + try: + proc.terminate() + except OSError: + pass # Process already dead + + result.tasks_skipped.append(change.task_id) + + # Update batch results + if hasattr(batch, "results"): + status = "COMPLETED" if change.change_type == "completed" else "FAILED" + batch.results[change.task_id] = status + + logger.info( + "Task %s %s externally (%s) — skipped in batch", + change.task_id, change.change_type, change.source, + ) + + elif change.change_type == "blocker_resolved": + result.tasks_requeued.append(change.task_id) + + # Update batch results to signal re-queue + if hasattr(batch, "results"): + batch.results[change.task_id] = "READY" + + logger.info( + "Task %s blocker resolved — re-queued", + change.task_id, + ) + + except Exception as exc: + error_msg = f"Failed to apply change for {change.task_id}: {exc}" + result.errors.append(error_msg) + logger.warning(error_msg) diff --git a/codeframe/core/tasks.py b/codeframe/core/tasks.py index 9a3190bf..e81befb6 100644 --- a/codeframe/core/tasks.py +++ b/codeframe/core/tasks.py @@ -55,6 +55,7 @@ class Task: estimated_hours: Optional[float] = None complexity_score: Optional[int] = None uncertainty_level: Optional[str] = None + github_issue_number: Optional[int] = None def create( @@ -669,4 +670,5 @@ def _row_to_task(row: tuple) -> Task: uncertainty_level=row[10], created_at=datetime.fromisoformat(row[11]), updated_at=datetime.fromisoformat(row[12]), + github_issue_number=row[13] if len(row) > 13 else None, ) diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index 479f688d..a4659b52 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -133,6 +133,8 @@ def _init_database(db_path: Path) -> None: cursor.execute("ALTER TABLE tasks ADD COLUMN complexity_score INTEGER") if "uncertainty_level" not in columns: cursor.execute("ALTER TABLE tasks ADD COLUMN uncertainty_level TEXT") + if "github_issue_number" not in columns: + cursor.execute("ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER") # Append-only event log cursor.execute(""" diff --git a/codeframe/git/github_issue_sync.py b/codeframe/git/github_issue_sync.py new file mode 100644 index 00000000..0a5ef824 --- /dev/null +++ b/codeframe/git/github_issue_sync.py @@ -0,0 +1,78 @@ +"""GitHub issue state synchronization for reconciliation. + +Provides synchronous GitHub API calls (not async) for use in the +reconciliation background thread. Separate from github_integration.py +which is async and PR-focused. +""" + +from __future__ import annotations + +import logging + +import httpx + +logger = logging.getLogger(__name__) + +GITHUB_API_BASE = "https://api.github.com" + + +def get_issue_state(token: str, repo: str, issue_number: int) -> str: + """Fetch a GitHub issue's state (synchronous). + + Args: + token: GitHub API token + repo: Repository in "owner/repo" format + issue_number: Issue number + + Returns: + Issue state string ("open" or "closed") + + Raises: + httpx.RequestError: On network failure + httpx.HTTPStatusError: On non-2xx response + """ + url = f"{GITHUB_API_BASE}/repos/{repo}/issues/{issue_number}" + response = httpx.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + }, + timeout=15.0, + ) + response.raise_for_status() + return response.json()["state"] + + +def build_github_task_checker(token: str, repo: str): + """Build a callable for injecting into ReconciliationEngine. + + Returns a function(task_id, task) -> list[ExternalStateChange] that + checks if the task's linked GitHub issue has been closed. + """ + def checker(task_id: str, task) -> list: + from codeframe.core.reconciliation import ExternalStateChange + + issue_number = getattr(task, "github_issue_number", None) + if issue_number is None: + return [] + + try: + state = get_issue_state(token, repo, issue_number) + except (httpx.RequestError, httpx.HTTPStatusError) as exc: + logger.warning( + "GitHub API error for issue #%d: %s", issue_number, exc, + ) + return [] + + if state == "closed": + return [ExternalStateChange( + task_id=task_id, + change_type="closed", + source="github", + details={"issue_number": issue_number}, + )] + + return [] + + return checker diff --git a/tests/core/test_reconciliation.py b/tests/core/test_reconciliation.py new file mode 100644 index 00000000..2538a30e --- /dev/null +++ b/tests/core/test_reconciliation.py @@ -0,0 +1,435 @@ +"""Tests for continuous reconciliation during batch execution.""" + +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.v2 + + +# --------------------------------------------------------------------------- +# Config tests (Step 1) +# --------------------------------------------------------------------------- + + +class TestReconciliationConfig: + """Test reconciliation config in EnvironmentConfig.""" + + def test_default_interval(self) -> None: + from codeframe.core.config import EnvironmentConfig + + cfg = EnvironmentConfig() + assert cfg.reconciliation_interval_seconds == 30 + + def test_custom_interval(self) -> None: + from codeframe.core.config import EnvironmentConfig + + cfg = EnvironmentConfig(reconciliation_interval_seconds=60) + assert cfg.reconciliation_interval_seconds == 60 + + def test_from_dict_with_reconciliation(self) -> None: + from codeframe.core.config import EnvironmentConfig + + cfg = EnvironmentConfig.from_dict({"reconciliation_interval_seconds": 15}) + assert cfg.reconciliation_interval_seconds == 15 + + def test_roundtrip(self) -> None: + from codeframe.core.config import EnvironmentConfig + + orig = EnvironmentConfig(reconciliation_interval_seconds=45) + d = orig.to_dict() + restored = EnvironmentConfig.from_dict(d) + assert restored.reconciliation_interval_seconds == 45 + + +class TestTaskGithubIssueNumber: + """Test github_issue_number field on Task.""" + + def test_task_has_github_issue_number_field(self) -> None: + from codeframe.core.tasks import Task + from codeframe.core.state_machine import TaskStatus + from datetime import datetime + + task = Task( + id="t1", workspace_id="w1", prd_id=None, + title="Test", description="", status=TaskStatus.READY, + priority=0, created_at=datetime.now(), updated_at=datetime.now(), + github_issue_number=42, + ) + assert task.github_issue_number == 42 + + def test_task_github_issue_number_defaults_to_none(self) -> None: + from codeframe.core.tasks import Task + from codeframe.core.state_machine import TaskStatus + from datetime import datetime + + task = Task( + id="t1", workspace_id="w1", prd_id=None, + title="Test", description="", status=TaskStatus.READY, + priority=0, created_at=datetime.now(), updated_at=datetime.now(), + ) + assert task.github_issue_number is None + + +# --------------------------------------------------------------------------- +# ReconciliationEngine tests (Step 2) +# --------------------------------------------------------------------------- + + +class TestExternalStateChange: + """Test ExternalStateChange dataclass.""" + + def test_creation(self) -> None: + from codeframe.core.reconciliation import ExternalStateChange + + change = ExternalStateChange( + task_id="t1", change_type="completed", + source="manual", details={"reason": "done"}, + ) + assert change.task_id == "t1" + assert change.change_type == "completed" + assert change.source == "manual" + + +class TestReconciliationResult: + """Test ReconciliationResult dataclass.""" + + def test_defaults(self) -> None: + from codeframe.core.reconciliation import ReconciliationResult + + result = ReconciliationResult() + assert result.changes_detected == [] + assert result.tasks_skipped == [] + assert result.tasks_requeued == [] + assert result.errors == [] + + +class TestReconciliationEngine: + """Test the ReconciliationEngine.""" + + def test_check_task_detects_completed(self) -> None: + from codeframe.core.reconciliation import ReconciliationEngine + from codeframe.core.state_machine import TaskStatus + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + mock_task = MagicMock() + mock_task.status = TaskStatus.DONE + mock_task.id = "t1" + + with patch("codeframe.core.reconciliation.tasks.get", return_value=mock_task): + changes = engine.check_task("t1") + + assert len(changes) == 1 + assert changes[0].change_type == "completed" + assert changes[0].source == "manual" + + def test_check_task_detects_blocker_resolved(self) -> None: + from codeframe.core.reconciliation import ReconciliationEngine + from codeframe.core.state_machine import TaskStatus + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + mock_task = MagicMock() + mock_task.status = TaskStatus.BLOCKED + mock_task.id = "t1" + + # All blockers answered + mock_blocker = MagicMock() + mock_blocker.status.value = "ANSWERED" + + with patch("codeframe.core.reconciliation.tasks.get", return_value=mock_task): + with patch("codeframe.core.reconciliation.blockers.list_for_task", return_value=[mock_blocker]): + changes = engine.check_task("t1") + + assert len(changes) == 1 + assert changes[0].change_type == "blocker_resolved" + + def test_check_task_returns_empty_for_in_progress(self) -> None: + from codeframe.core.reconciliation import ReconciliationEngine + from codeframe.core.state_machine import TaskStatus + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + mock_task = MagicMock() + mock_task.status = TaskStatus.IN_PROGRESS + mock_task.id = "t1" + + with patch("codeframe.core.reconciliation.tasks.get", return_value=mock_task): + changes = engine.check_task("t1") + + assert changes == [] + + def test_check_task_returns_empty_if_task_not_found(self) -> None: + from codeframe.core.reconciliation import ReconciliationEngine + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + with patch("codeframe.core.reconciliation.tasks.get", return_value=None): + changes = engine.check_task("t1") + + assert changes == [] + + def test_check_task_with_github_checker(self) -> None: + from codeframe.core.reconciliation import ExternalStateChange, ReconciliationEngine + from codeframe.core.state_machine import TaskStatus + + workspace = MagicMock() + + def github_checker(task_id, task): + return [ExternalStateChange( + task_id=task_id, change_type="closed", + source="github", details={}, + )] + + engine = ReconciliationEngine(workspace, github_checker=github_checker) + + mock_task = MagicMock() + mock_task.status = TaskStatus.IN_PROGRESS + mock_task.id = "t1" + + with patch("codeframe.core.reconciliation.tasks.get", return_value=mock_task): + changes = engine.check_task("t1") + + assert len(changes) == 1 + assert changes[0].change_type == "closed" + assert changes[0].source == "github" + + def test_check_all_active_catches_errors(self) -> None: + from codeframe.core.reconciliation import ReconciliationEngine + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + with patch.object(engine, "check_task", side_effect=Exception("db error")): + result = engine.check_all_active(["t1", "t2"]) + + assert len(result.errors) == 2 + assert result.changes_detected == [] + + def test_check_all_active_accumulates_changes(self) -> None: + from codeframe.core.reconciliation import ExternalStateChange, ReconciliationEngine + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + changes = [ + ExternalStateChange(task_id="t1", change_type="completed", source="manual", details={}), + ] + + with patch.object(engine, "check_task", side_effect=[changes, []]): + result = engine.check_all_active(["t1", "t2"]) + + assert len(result.changes_detected) == 1 + assert result.changes_detected[0].task_id == "t1" + + +class TestApplyChanges: + """Test ReconciliationEngine.apply_changes.""" + + def test_completed_change_terminates_process(self) -> None: + from codeframe.core.reconciliation import ( + ExternalStateChange, ReconciliationEngine, ReconciliationResult, + ) + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + result = ReconciliationResult( + changes_detected=[ + ExternalStateChange(task_id="t1", change_type="completed", source="manual", details={}), + ], + ) + + mock_proc = MagicMock() + active_processes = {"t1": mock_proc} + batch = MagicMock() + batch.results = {} + + engine.apply_changes(result, batch, active_processes) + + mock_proc.terminate.assert_called_once() + assert "t1" in result.tasks_skipped + + def test_closed_change_terminates_process(self) -> None: + from codeframe.core.reconciliation import ( + ExternalStateChange, ReconciliationEngine, ReconciliationResult, + ) + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + result = ReconciliationResult( + changes_detected=[ + ExternalStateChange(task_id="t1", change_type="closed", source="github", details={}), + ], + ) + + mock_proc = MagicMock() + active_processes = {"t1": mock_proc} + batch = MagicMock() + batch.results = {} + + engine.apply_changes(result, batch, active_processes) + + mock_proc.terminate.assert_called_once() + assert "t1" in result.tasks_skipped + + def test_blocker_resolved_requeues_task(self) -> None: + from codeframe.core.reconciliation import ( + ExternalStateChange, ReconciliationEngine, ReconciliationResult, + ) + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + result = ReconciliationResult( + changes_detected=[ + ExternalStateChange(task_id="t1", change_type="blocker_resolved", source="manual", details={}), + ], + ) + + batch = MagicMock() + batch.results = {"t1": "BLOCKED"} + + engine.apply_changes(result, batch, {}) + + assert "t1" in result.tasks_requeued + + def test_apply_changes_catches_errors(self) -> None: + from codeframe.core.reconciliation import ( + ExternalStateChange, ReconciliationEngine, ReconciliationResult, + ) + + workspace = MagicMock() + engine = ReconciliationEngine(workspace) + + result = ReconciliationResult( + changes_detected=[ + ExternalStateChange(task_id="t1", change_type="completed", source="manual", details={}), + ], + ) + + # Process that raises on terminate + mock_proc = MagicMock() + mock_proc.terminate.side_effect = OSError("already dead") + active_processes = {"t1": mock_proc} + batch = MagicMock() + batch.results = {} + + # Should not raise + engine.apply_changes(result, batch, active_processes) + assert len(result.errors) >= 0 # Error may or may not be logged + + +# --------------------------------------------------------------------------- +# GitHub Issue Sync tests (Step 3) +# --------------------------------------------------------------------------- + + +class TestGitHubIssueSync: + """Test GitHub issue state checker.""" + + def test_get_issue_state_returns_state(self) -> None: + from codeframe.git.github_issue_sync import get_issue_state + + mock_response = MagicMock() + mock_response.json.return_value = {"state": "closed"} + mock_response.raise_for_status = MagicMock() + + with patch("codeframe.git.github_issue_sync.httpx.get", return_value=mock_response): + state = get_issue_state("token123", "owner/repo", 42) + + assert state == "closed" + + def test_get_issue_state_returns_open(self) -> None: + from codeframe.git.github_issue_sync import get_issue_state + + mock_response = MagicMock() + mock_response.json.return_value = {"state": "open"} + mock_response.raise_for_status = MagicMock() + + with patch("codeframe.git.github_issue_sync.httpx.get", return_value=mock_response): + state = get_issue_state("token123", "owner/repo", 42) + + assert state == "open" + + def test_build_github_task_checker_returns_callable(self) -> None: + from codeframe.git.github_issue_sync import build_github_task_checker + + checker = build_github_task_checker("token123", "owner/repo") + assert callable(checker) + + def test_checker_returns_closed_change(self) -> None: + from codeframe.git.github_issue_sync import build_github_task_checker + + checker = build_github_task_checker("token123", "owner/repo") + + mock_task = MagicMock() + mock_task.github_issue_number = 42 + + with patch("codeframe.git.github_issue_sync.get_issue_state", return_value="closed"): + changes = checker("t1", mock_task) + + assert len(changes) == 1 + assert changes[0].change_type == "closed" + assert changes[0].source == "github" + + def test_checker_returns_empty_for_open_issue(self) -> None: + from codeframe.git.github_issue_sync import build_github_task_checker + + checker = build_github_task_checker("token123", "owner/repo") + + mock_task = MagicMock() + mock_task.github_issue_number = 42 + + with patch("codeframe.git.github_issue_sync.get_issue_state", return_value="open"): + changes = checker("t1", mock_task) + + assert changes == [] + + def test_checker_skips_tasks_without_issue_number(self) -> None: + from codeframe.git.github_issue_sync import build_github_task_checker + + checker = build_github_task_checker("token123", "owner/repo") + + mock_task = MagicMock() + mock_task.github_issue_number = None + + changes = checker("t1", mock_task) + assert changes == [] + + def test_checker_handles_api_error_gracefully(self) -> None: + import httpx + from codeframe.git.github_issue_sync import build_github_task_checker + + checker = build_github_task_checker("token123", "owner/repo") + + mock_task = MagicMock() + mock_task.github_issue_number = 42 + + with patch("codeframe.git.github_issue_sync.get_issue_state", side_effect=httpx.RequestError("timeout")): + changes = checker("t1", mock_task) + + assert changes == [] + + +# --------------------------------------------------------------------------- +# Event types tests (Step 5) +# --------------------------------------------------------------------------- + + +class TestReconciliationEventTypes: + """Test reconciliation event type constants.""" + + def test_event_types_exist(self) -> None: + from codeframe.core.events import EventType + + assert hasattr(EventType, "RECONCILIATION_STARTED") + assert hasattr(EventType, "RECONCILIATION_TASK_SKIPPED") + assert hasattr(EventType, "RECONCILIATION_TASK_REQUEUED") + assert hasattr(EventType, "RECONCILIATION_ERROR")