diff --git a/src/agent_gitea/db.py b/src/agent_gitea/db.py index c44a06a..069e076 100644 --- a/src/agent_gitea/db.py +++ b/src/agent_gitea/db.py @@ -207,6 +207,17 @@ class Database: ).fetchall() return self._task(rows[0]) if rows else None + def task_for_issue(self, repo_id: int, issue_number: int) -> TaskRecord | None: + row = self.conn.execute( + """ + SELECT * FROM tasks + WHERE repo_id = ? AND issue_number = ? + ORDER BY id DESC LIMIT 1 + """, + (repo_id, issue_number), + ).fetchone() + return self._task(row) if row else None + def create_task(self, repo_id: int, issue_number: int) -> TaskRecord: now = dt_to_db(utcnow()) self.conn.execute( @@ -322,13 +333,23 @@ class Database: return updated def retry_task(self, task_id: int) -> TaskRecord: - return self.transition( - task_id, - TaskState.DISCOVERED, - message="manual retry", - error_message="", - clear_lease=True, + task = self.get_task(task_id) + if task is None: + raise ValueError(f"task not found: {task_id}") + now = dt_to_db(utcnow()) + self.conn.execute( + """ + UPDATE tasks + SET state = ?, lease_owner = NULL, lease_expires_at = NULL, error_message = NULL, updated_at = ? + WHERE id = ? + """, + (TaskState.DISCOVERED.value, now, task_id), ) + self.conn.commit() + self.add_event(task_id, task.state, TaskState.DISCOVERED, "manual retry") + updated = self.get_task(task_id) + assert updated is not None + return updated def cancel_task(self, task_id: int) -> TaskRecord: return self.transition(task_id, TaskState.CANCELLED, message="manual cancellation", clear_lease=True) diff --git a/src/agent_gitea/scanner.py b/src/agent_gitea/scanner.py index 6902e48..f7cee83 100644 --- a/src/agent_gitea/scanner.py +++ b/src/agent_gitea/scanner.py @@ -31,7 +31,7 @@ def scan_eligible_issues( for issue in db.list_open_issues(repo.id): if not is_issue_eligible(issue, labels): continue - if db.active_task_for_issue(repo.id, issue.issue_number): + if db.task_for_issue(repo.id, issue.issue_number): continue task = db.create_task(repo.id, issue.issue_number) created.append(task.id) diff --git a/src/agent_gitea/service.py b/src/agent_gitea/service.py index 4c55bd6..7057e24 100644 --- a/src/agent_gitea/service.py +++ b/src/agent_gitea/service.py @@ -195,8 +195,8 @@ class TaskRunner: write_prompt(prompt_path, prompt) command = render_command( self.config.agents.implementer.command, - workspace_path=workspace, - prompt_path=prompt_path, + workspace_path=workspace.resolve(), + prompt_path=prompt_path.resolve(), issue_number=issue.issue_number, issue_title=issue.title, branch_name=branch_name, @@ -230,8 +230,8 @@ class TaskRunner: write_prompt(prompt_path, prompt) command = render_command( self.config.agents.reviewer.command, - workspace_path=workspace, - prompt_path=prompt_path, + workspace_path=workspace.resolve(), + prompt_path=prompt_path.resolve(), issue_number=issue.issue_number, issue_title=issue.title, pr_number=pr_number, diff --git a/tests/conftest.py b/tests/conftest.py index df3f412..48c746b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,8 @@ def make_config(tmp_path: Path, **overrides: object) -> AppConfig: "scheduler": {"interval_seconds": 1, "concurrency": 1, "lease_seconds": 60}, "workspace": {"root": tmp_path / "workspaces", "cleanup_on_success": False}, "agents": { - "implementer": {"command": ["implementer", "-"]}, - "reviewer": {"command": ["reviewer", "-"]}, + "implementer": {"command": ["implementer", "--cd", "{workspace_path}", "-"]}, + "reviewer": {"command": ["reviewer", "--cd", "{workspace_path}", "-"]}, }, } data.update(overrides) diff --git a/tests/test_gitea_service.py b/tests/test_gitea_service.py index 3552cf7..640e649 100644 --- a/tests/test_gitea_service.py +++ b/tests/test_gitea_service.py @@ -18,8 +18,8 @@ def make_config(tmp_path: Path, **overrides: object) -> AppConfig: "scheduler": {"interval_seconds": 1, "concurrency": 1, "lease_seconds": 60}, "workspace": {"root": tmp_path / "workspaces", "cleanup_on_success": False}, "agents": { - "implementer": {"command": ["implementer", "-"]}, - "reviewer": {"command": ["reviewer", "-"]}, + "implementer": {"command": ["implementer", "--cd", "{workspace_path}", "-"]}, + "reviewer": {"command": ["reviewer", "--cd", "{workspace_path}", "-"]}, }, } data.update(overrides) @@ -208,6 +208,9 @@ def test_run_task_success_posts_review_comments(db, tmp_path): assert task is not None assert task.state == TaskState.HUMAN_REVIEW_READY assert task.pr_number == 5 + command = json.loads(db.list_agent_runs(task.id)[0]["command_json"]) + assert command[1] == "--cd" + assert Path(command[2]).is_absolute() assert [path for _, path, _ in requests].count("/api/v1/repos/acme/service/issues/5/comments") == 2 diff --git a/tests/test_scanner_state.py b/tests/test_scanner_state.py index e780ba0..e8f2421 100644 --- a/tests/test_scanner_state.py +++ b/tests/test_scanner_state.py @@ -69,7 +69,7 @@ def test_scan_ignores_repositories_not_allowed_by_current_config(db): assert created == [] -def test_retry_after_terminal_task_allows_new_scan_task(db): +def test_terminal_task_does_not_create_scan_loop(db): repo = db.upsert_repository(owner="acme", name="service", clone_url="x", default_branch="main", enabled=True) db.upsert_issue( repo_id=repo.id, @@ -88,8 +88,7 @@ def test_retry_after_terminal_task_allows_new_scan_task(db): created = scan_eligible_issues(db, LabelsConfig()) - assert len(created) == 1 - assert created[0] != task_id + assert created == [] def test_claim_next_task_claims_expired_lease(db): @@ -111,6 +110,21 @@ def test_claim_next_task_claims_expired_lease(db): assert reclaimed.lease_owner == "worker-b" +def test_retry_task_clears_active_lease(db): + repo = db.upsert_repository(owner="acme", name="service", clone_url="x", default_branch="main", enabled=True) + task = db.create_task(repo.id, 1) + claimed = db.claim_next_task("worker-a", 60) + assert claimed is not None + db.transition(claimed.id, TaskState.PLANNING, error_message="stuck") + + retried = db.retry_task(task.id) + + assert retried.state == TaskState.DISCOVERED + assert retried.lease_owner is None + assert retried.lease_expires_at is None + assert retried.error_message is None + + def test_state_transition_validation(): validate_transition(TaskState.DISCOVERED, TaskState.CLAIMED)