import asyncio from dataclasses import replace from pathlib import Path from types import SimpleNamespace import pytest from agentci.application.worker.errors import safe_error from agentci.application.worker.runner import Worker from agentci.engine.events import ( CommentLinked, JobCompleted, JobFailed, JobStarted, PermissionDenied, PermissionGranted, ServiceRestarted, ) from agentci.engine.events import ( JobRejected as RejectedEvent, ) from agentci.engine.model import ( IncomingCommand, Job, JobKind, JobStatus, QueueName, Task, TaskKind, Workflow, WorkflowKind, ) from agentci.engine.reducer import render_job_comment from agentci.engine.repository import Repository from agentci.integrations.gitea.models import CommentInfo from agentci.workflows.render import JobRejected MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" class FakeRepository: def __init__( self, value: Job | None = None, *, workflow: Workflow | None = None, ) -> None: self.job = value self.workflow = workflow self.tasks: list[Task] = [] self.claimed_queues: list[QueueName] = [] self.completed: list[int] = [] self.retried: list[tuple[int, int, str]] = [] self.events: list[tuple[str, object]] = [] self.operations: list[str] = [] self.running: list[Job] = [] self.failed_workflows: list[str] = [] self.complete_stop: asyncio.Event | None = None async def claim_task(self, queue: QueueName) -> Task | None: self.claimed_queues.append(queue) return self.tasks.pop(0) if self.tasks else None async def complete_task(self, task_id: int) -> None: self.completed.append(task_id) if self.complete_stop is not None: self.complete_stop.set() async def retry_task(self, task_id: int, attempts: int, error: str) -> None: self.retried.append((task_id, attempts, error)) async def get_job(self, _job_id: str) -> Job | None: return self.job async def apply(self, event_id: str, event: object) -> SimpleNamespace: self.operations.append(f"apply:{event_id}") self.events.append((event_id, event)) if isinstance(event, JobStarted) and self.job is not None: self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting") elif isinstance(event, CommentLinked) and self.job is not None: self.job = replace(self.job, accepted_comment_id=event.comment_id) return SimpleNamespace(job=self.job) async def recover_tasks(self) -> None: self.operations.append("recover_tasks") async def running_jobs(self) -> list[Job]: self.operations.append("running_jobs") return self.running async def get_workflow(self, _workflow_id: str) -> Workflow | None: return self.workflow async def fail_job_workflow(self, job_id: str) -> None: self.failed_workflows.append(job_id) class FakeGitea: def __init__(self, *, permitted: bool = True) -> None: self.permitted = permitted self.permission_calls: list[tuple[str, str, str]] = [] self.update_results: list[bool] = [True] self.update_calls: list[tuple[str, str, int, str]] = [] self.comments: list[CommentInfo] = [] self.issue_comment_calls: list[tuple[str, str, int]] = [] self.created_comment_id = 42 self.create_calls: list[tuple[str, str, int, str]] = [] async def has_write_permission(self, owner: str, repo: str, user: str) -> bool: self.permission_calls.append((owner, repo, user)) return self.permitted async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool: self.update_calls.append((owner, repo, comment_id, body)) if len(self.update_results) > 1: return self.update_results.pop(0) return self.update_results[0] async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]: self.issue_comment_calls.append((owner, repo, issue)) return self.comments async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int: self.create_calls.append((owner, repo, issue, body)) return self.created_comment_id class FakeOpenCode: def __init__(self, ready: list[bool] | None = None) -> None: self.ready_results = ready or [True] self.ready_calls = 0 self.aborted: list[tuple[str, Path]] = [] async def ready(self) -> bool: self.ready_calls += 1 if len(self.ready_results) > 1: return self.ready_results.pop(0) return self.ready_results[0] async def abort(self, session_id: str, workspace: Path) -> None: self.aborted.append((session_id, workspace)) def job( *, job_id: str = "job", status: JobStatus = JobStatus.RECEIVED, workflow_id: str | None = None, session_id: str | None = None, accepted_comment_id: int | None = None, ) -> Job: return Job( id=job_id, target_key="org/repo:issue:1", repo_owner="org", repo_name="repo", issue_number=1, pr_number=None, requester="alice", comment_id=1, delivery_id=f"delivery-{job_id}", receive_sequence=1, command_body="/agent plan", kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None, status=status, stage=status.value, workflow_id=workflow_id, runtime_session_id=session_id, accepted_comment_id=accepted_comment_id, ) def task( kind: TaskKind = TaskKind.EXECUTE, queue: QueueName = QueueName.JOBS, *, task_id: int = 7, ) -> Task: return Task(task_id, "job", "source", kind, queue, 2) def make_worker( tmp_path: Path, repository: FakeRepository | Repository, *, gitea: FakeGitea | None = None, opencode: FakeOpenCode | None = None, max_concurrent_jobs: int = 2, ) -> Worker: return Worker( repository=repository, # type: ignore[arg-type] gitea=gitea or FakeGitea(), # type: ignore[arg-type] opencode=opencode or FakeOpenCode(), # type: ignore[arg-type] services=SimpleNamespace(), # type: ignore[arg-type] poll_seconds=1, max_concurrent_jobs=max_concurrent_jobs, workspaces_dir=tmp_path, bot_username="agentci", ) async def test_loop_completes_successful_task( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: repository = FakeRepository() queued_task = task(queue=QueueName.CONTROL) repository.tasks = [queued_task] stop = asyncio.Event() handled: list[Task] = [] value = make_worker(tmp_path, repository) async def handle(current: Task) -> None: handled.append(current) stop.set() monkeypatch.setattr(value, "_handle", handle) await value._loop(QueueName.CONTROL, stop) assert handled == [queued_task] assert repository.completed == [queued_task.id] assert repository.retried == [] async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: repository = FakeRepository() queued_task = task(queue=QueueName.CONTROL) repository.tasks = [queued_task] stop = asyncio.Event() value = make_worker(tmp_path, repository) async def fail(_current: Task) -> None: stop.set() raise RuntimeError("provider\nfailed") monkeypatch.setattr(value, "_handle", fail) await value._loop(QueueName.CONTROL, stop) assert repository.completed == [] assert repository.retried == [ (queued_task.id, queued_task.attempts, "RuntimeError: provider failed") ] async def test_loop_propagates_cancellation_without_rescheduling( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: repository = FakeRepository() queued_task = task(queue=QueueName.CONTROL) repository.tasks = [queued_task] stop = asyncio.Event() value = make_worker(tmp_path, repository) async def cancel(_current: Task) -> None: stop.set() raise asyncio.CancelledError monkeypatch.setattr(value, "_handle", cancel) with pytest.raises(asyncio.CancelledError): await value._loop(QueueName.CONTROL, stop) assert repository.completed == [] assert repository.retried == [] async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None: repository = FakeRepository(None) missing_job_task = task(queue=QueueName.CONTROL) repository.tasks = [missing_job_task] stop = asyncio.Event() repository.complete_stop = stop await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop) assert repository.completed == [missing_job_task.id] assert repository.retried == [] async def test_jobs_queue_waits_for_provider_readiness( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: repository = FakeRepository() queued_task = task() repository.tasks = [queued_task] opencode = FakeOpenCode([False, True]) stop = asyncio.Event() waits: list[asyncio.Event] = [] value = make_worker(tmp_path, repository, opencode=opencode) async def wait(current_stop: asyncio.Event) -> None: waits.append(current_stop) async def handle(_current: Task) -> None: stop.set() monkeypatch.setattr(value, "_wait", wait) monkeypatch.setattr(value, "_handle", handle) await value._loop(QueueName.JOBS, stop) assert opencode.ready_calls == 2 assert waits == [stop] assert repository.claimed_queues == [QueueName.JOBS] assert repository.completed == [queued_task.id] async def test_run_recovers_before_starting_configured_consumers( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3) calls: list[str | QueueName] = [] async def recover() -> None: calls.append("recover") async def loop(queue: QueueName, _stop: asyncio.Event) -> None: calls.append(queue) monkeypatch.setattr(value, "_recover", recover) monkeypatch.setattr(value, "_loop", loop) await value.run(asyncio.Event()) assert calls[0] == "recover" assert calls.count(QueueName.CONTROL) == 1 assert calls.count(QueueName.JOBS) == 3 @pytest.mark.parametrize( ("permitted", "event_type", "suffix"), [ (True, PermissionGranted, "permission-granted"), (False, PermissionDenied, "permission-denied"), ], ) async def test_authorize_records_permission_outcome( tmp_path: Path, permitted: bool, event_type: type[PermissionGranted] | type[PermissionDenied], suffix: str, ) -> None: current = job() repository = FakeRepository(current) gitea = FakeGitea(permitted=permitted) authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL) await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current) assert gitea.permission_calls == [("org", "repo", "alice")] assert repository.events[0][0] == f"task:{authorize.id}:{suffix}" assert isinstance(repository.events[0][1], event_type) async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None: current = job(status=JobStatus.QUEUED) repository = FakeRepository(current) gitea = FakeGitea() await make_worker(tmp_path, repository, gitea=gitea)._authorize( task(TaskKind.AUTHORIZE, QueueName.CONTROL), current ) assert gitea.permission_calls == [] assert repository.events == [] async def test_authorize_integrates_with_real_repository( tmp_path: Path, ) -> None: repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS) await repository.initialize() accepted = await repository.accept( IncomingCommand( delivery_id="delivery-real", comment_id=1, repo_owner="org", repo_name="repo", issue_number=1, pr_number=None, requester="alice", body="/agent plan write tests", ) ) authorize = await repository.claim_task(QueueName.CONTROL) assert authorize is not None assert authorize.kind is TaskKind.AUTHORIZE await make_worker(tmp_path, repository)._handle(authorize) persisted = await repository.get_job(accepted.job.id) assert persisted is not None assert persisted.status is JobStatus.QUEUED assert persisted.kind is JobKind.PLAN assert persisted.message == "write tests" execute = await repository.claim_task(QueueName.JOBS) assert execute is not None assert execute.kind is TaskKind.EXECUTE async def test_execute_completes_with_workflow_comment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: current = job(status=JobStatus.QUEUED) repository = FakeRepository(current) value = make_worker(tmp_path, repository) async def dispatch(_job: Job, _run: object, _services: object) -> str: return "final workflow body" monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) execute = task() await value._execute(execute, current) assert [event_id for event_id, _ in repository.events] == [ f"task:{execute.id}:started", f"task:{execute.id}:completed", ] assert isinstance(repository.events[0][1], JobStarted) completed = repository.events[1][1] assert isinstance(completed, JobCompleted) assert completed.comment_body == "final workflow body" async def test_execute_records_expected_rejection( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: current = job(status=JobStatus.QUEUED) repository = FakeRepository(current) async def dispatch(_job: Job, _run: object, _services: object) -> str: raise JobRejected("pull request is closed") monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) await make_worker(tmp_path, repository)._execute(task(), current) rejected = repository.events[-1][1] assert isinstance(rejected, RejectedEvent) assert rejected.reason == "pull request is closed" async def test_execute_records_failure_at_latest_persisted_stage( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: current = job(status=JobStatus.QUEUED) repository = FakeRepository(current) async def dispatch(_job: Job, _run: object, _services: object) -> str: assert repository.job is not None repository.job = replace(repository.job, stage="cloning") raise RuntimeError("provider\nfailed") monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) await make_worker(tmp_path, repository)._execute(task(), current) failed = repository.events[-1][1] assert isinstance(failed, JobFailed) assert failed.stage == "cloning" assert failed.error == "RuntimeError: provider failed" async def test_execute_marks_running_job_interrupted_after_restart( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: current = job(status=JobStatus.RUNNING) repository = FakeRepository(current) async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str: pytest.fail("running jobs must not be dispatched again") monkeypatch.setattr("agentci.application.worker.execution.dispatch", unexpected_dispatch) execute = task() await make_worker(tmp_path, repository)._execute(execute, current) assert repository.events[0][0] == f"task:{execute.id}:interrupted" assert isinstance(repository.events[0][1], ServiceRestarted) @pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED]) async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None: current = job(status=status) repository = FakeRepository(current) await make_worker(tmp_path, repository)._execute(task(), current) assert repository.events == [] async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None: current = job(status=JobStatus.QUEUED, accepted_comment_id=19) repository = FakeRepository(current) gitea = FakeGitea() await make_worker(tmp_path, repository, gitea=gitea)._reconcile( task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current ) assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))] assert gitea.issue_comment_calls == [] assert gitea.create_calls == [] assert repository.events == [] async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it( tmp_path: Path, ) -> None: current = job(status=JobStatus.QUEUED, accepted_comment_id=99) marker = f"" repository = FakeRepository(current) gitea = FakeGitea() gitea.update_results = [False, True] gitea.comments = [ CommentInfo(8, "agentci", f"{marker}\nnewer", ""), CommentInfo(3, "AgentCI", f"{marker}\nolder", ""), CommentInfo(1, "alice", f"{marker}\nnot the bot", ""), CommentInfo(2, "agentci", f"prefix {marker}", ""), ] reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current) assert gitea.issue_comment_calls == [("org", "repo", 1)] assert gitea.create_calls == [] assert [call[2] for call in gitea.update_calls] == [99, 3] assert repository.events[0][0] == f"task:{reconcile.id}:comment:3" linked = repository.events[0][1] assert isinstance(linked, CommentLinked) assert linked.comment_id == 3 async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None: current = job(status=JobStatus.SUCCEEDED) repository = FakeRepository(current) gitea = FakeGitea() gitea.comments = [CommentInfo(1, "alice", "unrelated", "")] reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current) body = render_job_comment(current) assert gitea.create_calls == [("org", "repo", 1, body)] assert gitea.update_calls == [("org", "repo", 42, body)] assert repository.events[0][0] == f"task:{reconcile.id}:comment:42" linked = repository.events[0][1] assert isinstance(linked, CommentLinked) assert linked.comment_id == 42 async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None: repository = FakeRepository() repository.running = [ job(job_id="first", status=JobStatus.RUNNING), job(job_id="second", status=JobStatus.RUNNING), ] await make_worker(tmp_path, repository)._recover() assert repository.operations == [ "recover_tasks", "running_jobs", "apply:recovery:first:service-restarted", "apply:recovery:second:service-restarted", ] assert all(isinstance(event, ServiceRestarted) for _, event in repository.events) @pytest.mark.parametrize( ("primary", "reviewer", "expected"), [ ("primary", "reviewer", {"primary", "reviewer"}), ("same-session", "same-session", {"same-session"}), ], ) async def test_abort_collects_and_deduplicates_workflow_sessions( tmp_path: Path, primary: str, reviewer: str, expected: set[str], ) -> None: workspace = tmp_path / "workflow" / "repo" workflow = Workflow( id="flow", kind=WorkflowKind.IMPLEMENT, repo_owner="org", repo_name="repo", issue_number=1, workspace_path=workspace, base_sha="base", primary_session_id=primary, reviewer_session_id=reviewer, ) opencode = FakeOpenCode() await make_worker( tmp_path, FakeRepository(workflow=workflow), opencode=opencode )._abort_job_sessions(job(workflow_id="flow")) assert set(opencode.aborted) == {(session, workspace) for session in expected} async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None: opencode = FakeOpenCode() await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions( job(workflow_id="missing", session_id="session") ) assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")] async def test_abort_does_not_mix_one_shot_session_into_existing_workflow( tmp_path: Path, ) -> None: workflow = Workflow( id="flow", kind=WorkflowKind.IMPLEMENT, repo_owner="org", repo_name="repo", issue_number=1, workspace_path=tmp_path / "workflow" / "repo", base_sha="base", ) opencode = FakeOpenCode() await make_worker( tmp_path, FakeRepository(workflow=workflow), opencode=opencode )._abort_job_sessions(job(workflow_id="flow", session_id="one-shot")) assert opencode.aborted == [] async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None: opencode = FakeOpenCode() await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job()) assert opencode.aborted == [] def test_safe_error_is_single_line_and_bounded() -> None: value = safe_error(RuntimeError("bad\n" + "x" * 2000)) assert "\n" not in value assert len(value) == 1000