feat: implement Gitea notification polling and state persistence

Squash merged Gitea notification polling and state persistence.
This commit was merged in pull request #1.
This commit is contained in:
2026-06-30 21:31:59 +02:00
parent edc8415571
commit 287d20bc56
4 changed files with 281 additions and 39 deletions
+112 -37
View File
@@ -1,13 +1,16 @@
"""Top-level coordinator: polls Gitea, queues work, dispatches to agent."""
"""Top-level coordinator: polls Gitea unread notifications, queues work, dispatches to agent."""
import asyncio
import logging
import datetime
import json
from pathlib import Path
from typing import Any
from typing import Any, Optional
from core.queue import WorkQueue, WorkItem
from core.dispatcher import AgentDispatcher
from gitea.client import GiteaClient
from gitea.models import IssueModel, PullRequestModel
from gitea.models import IssueModel, PullRequestModel, RepositoryModel
from gitea.tools.gitea_tools import GiteaTools
from gitea.config import AGENT_MODEL_ID, AGENT_MAX_RETRIES
from gitea.workspace import WorkspaceManager
@@ -16,7 +19,7 @@ logger: logging.Logger = logging.getLogger("agent-orchestrator")
class AgentOrchestrator:
"""Top-level coordinator: polls Gitea, queues work, dispatches to agent."""
"""Top-level coordinator: polls Gitea notifications, queues work, dispatches to agent."""
def __init__(
self,
@@ -31,51 +34,120 @@ class AgentOrchestrator:
self._work_queue = WorkQueue()
self._dispatcher = AgentDispatcher(client, tools, model_name, max_retries)
def _get_state_file_path(self) -> Path:
"""Get the path to the persistent state file."""
return Path(__file__).parent.parent / "agent_state.json"
def _read_last_checked(self) -> Optional[str]:
"""Read the last checked timestamp from persistent storage."""
state_file = self._get_state_file_path()
if state_file.exists():
try:
with open(state_file, "r") as f:
data = json.load(f)
return data.get("last_checked")
except Exception as e:
logger.warning(f"Error reading agent_state.json: {e}")
return None
def _write_last_checked(self, timestamp: str) -> None:
"""Write the last checked timestamp to persistent storage."""
state_file = self._get_state_file_path()
try:
with open(state_file, "w") as f:
json.dump({"last_checked": timestamp}, f)
except Exception as e:
logger.error(f"Error writing agent_state.json: {e}")
async def poll_and_dispatch(self) -> None:
"""Poll Gitea for tasks, enqueue them, and dispatch to agent."""
issues: list[IssueModel] = self._client.list_assigned_issues()
prs: list[PullRequestModel] = self._client.list_assigned_pull_requests()
"""Poll Gitea unread notifications, enqueue them, and dispatch to agent."""
last_checked = self._read_last_checked()
logger.info(f"Polling unread notifications since: {last_checked or 'beginning'}")
if issues:
logger.info(f"Found {len(issues)} assigned issues")
self._enqueue_tasks("issue", issues)
else:
logger.info("No assigned issues found.")
notifications = self._client.list_unread_notifications(since=last_checked)
if prs:
logger.info(f"Found {len(prs)} assigned PRs")
self._enqueue_tasks("pr", prs)
else:
logger.info("No assigned PRs found.")
if not notifications:
logger.info("No new notifications found.")
return
logger.info(f"Retrieved {len(notifications)} unread notifications.")
# Filter and enqueue tasks from notifications
latest_timestamp = last_checked
for n in notifications:
notification_id = n.get("id")
subject = n.get("subject") or {}
subj_type = subject.get("type", "").lower()
subj_url = subject.get("url", "")
updated_at = n.get("updated_at")
# Track latest updated_at to advance checkpoint
if updated_at and (not latest_timestamp or updated_at > latest_timestamp):
latest_timestamp = updated_at
repo_info = n.get("repository") or {}
repo_full_name = repo_info.get("full_name", "")
if not repo_full_name or not subj_url:
continue
owner, repo_name = repo_full_name.split("/")
try:
task_number = int(subj_url.rstrip("/").split("/")[-1])
except (ValueError, IndexError):
logger.warning(f"Could not parse task number from subject URL: {subj_url}")
continue
if subj_type == "issue":
try:
issue = self._client.get_issue(owner, repo_name, task_number)
if issue.repository is None:
issue = issue.model_copy(update={"repository": RepositoryModel(**repo_info)})
item = WorkItem(
repo_full_name=repo_full_name,
task_type="issue",
task_number=task_number,
task_info=issue,
notification_id=notification_id,
priority=0
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch issue #{task_number} for notification: {e}")
elif subj_type in ("pull", "pullrequest"):
try:
pr = self._client.get_pull_request(owner, repo_name, task_number)
if pr.repository is None:
pr = pr.model_copy(update={"repository": RepositoryModel(**repo_info)})
item = WorkItem(
repo_full_name=repo_full_name,
task_type="pr",
task_number=task_number,
task_info=pr,
notification_id=notification_id,
priority=0
)
self._work_queue.enqueue(item)
except Exception as e:
logger.error(f"Failed to fetch PR #{task_number} for notification: {e}")
# Process enqueued work
if not self._work_queue.is_empty:
await self._process_work()
def _enqueue_tasks(self, task_type: str, tasks: list[IssueModel] | list[PullRequestModel]) -> None:
for task in tasks:
repo_full_name: str | None = task.repository.full_name if task.repository else None
task_number: int = task.number
if not repo_full_name or not task_number:
continue
item = WorkItem(
repo_full_name=repo_full_name,
task_type=task_type,
task_number=task_number,
task_info=task,
priority=0,
)
self._work_queue.enqueue(item)
logger.info(f"Enqueued {task_type} #{task_number} from {repo_full_name}")
# Update last checked timestamp checkpoint
if latest_timestamp:
self._write_last_checked(latest_timestamp)
async def _process_work(self) -> None:
"""Process all queued work, repo by repo."""
"""Process all queued work, repo by repo, then mark notifications as read."""
while not self._work_queue.is_empty:
repo: str | None = self._work_queue.get_next_repo()
repo = self._work_queue.get_next_repo()
if not repo:
break
work_items: list[WorkItem] = self._work_queue.get_repo_work(repo)
work_items = self._work_queue.get_repo_work(repo)
self._work_queue.remove_repo_work(repo)
# Ensure the workspace repository is cloned and sanitized
@@ -89,8 +161,11 @@ class AgentOrchestrator:
logger.info(f"Sanitized existing repo at {repo_path}")
logger.info(f"Dispatching {len(work_items)} tasks for {repo}")
results: list[str] = await self._dispatcher.dispatch(repo, work_items)
results = await self._dispatcher.dispatch(repo, work_items)
for i, result in enumerate(results):
item = work_items[i]
logger.info(f"Completed {item.task_type} #{item.task_number}: {result[:200]}")
if item.notification_id is not None:
self._client.mark_notification_as_read(item.notification_id)
logger.info(f"Marked Gitea notification thread {item.notification_id} as read.")
+2 -1
View File
@@ -1,6 +1,6 @@
import logging
from pydantic import BaseModel
from typing import Any
from typing import Any, Optional
from gitea.models import IssueModel, PullRequestModel
logger: logging.Logger = logging.getLogger("work-queue")
@@ -11,6 +11,7 @@ class WorkItem(BaseModel):
task_type: str # 'issue' or 'pr'
task_number: int
task_info: IssueModel | PullRequestModel
notification_id: Optional[int] = None
priority: int = 0