diff --git a/backend/app/models/watched_repo.py b/backend/app/models/watched_repo.py index 0930f29..a99ac48 100644 --- a/backend/app/models/watched_repo.py +++ b/backend/app/models/watched_repo.py @@ -91,3 +91,20 @@ def delete(watched_id: str) -> bool: .execute() ) return len(result.data) > 0 + + @staticmethod + def delete_by_agent(agent_id: str) -> None: + """Remove every watched_repos row for an agent. + + Called by Orchestrator.stop_agent so an offboarded agent stops + occupying its (user, owner, repo) slot — otherwise the cross-agent + uniqueness from #28 would block re-hiring against the same repo + (#31). Fire-and-forget: we don't care about the row count. + """ + ( + get_supabase() + .table(TABLE) + .delete() + .eq("agent_id", agent_id) + .execute() + ) diff --git a/backend/app/services/orchestrator.py b/backend/app/services/orchestrator.py index 5998aee..e081a45 100644 --- a/backend/app/services/orchestrator.py +++ b/backend/app/services/orchestrator.py @@ -5,6 +5,7 @@ from docker.errors import NotFound, APIError from app.config import get_settings from app.models.agent import AgentModel +from app.models.watched_repo import WatchedRepoModel from app.services.template_loader import load_template @@ -95,6 +96,11 @@ def stop_agent(self, agent_id: str) -> dict | None: except NotFound: pass + # Release the agent's repo subscriptions so a re-hire under the same + # user can take the same (owner, repo) — #31. agent_memory / + # agent_action_log / reviewed_prs are preserved as the audit trail. + WatchedRepoModel.delete_by_agent(agent_id) + # Clear the token: a stopped agent must not authenticate. AgentModel.update( agent_id, status="stopped", container_id=None, agent_token=None diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py index 3c20e20..c02ad93 100644 --- a/backend/tests/test_orchestrator.py +++ b/backend/tests/test_orchestrator.py @@ -223,6 +223,34 @@ def test_stop_agent_not_found(self, fake_supabase, mock_docker): result = orch.stop_agent("nonexistent") assert result is None + def test_stop_agent_releases_watched_repos(self, fake_supabase, mock_docker): + """Offboarding must release the agent's repo subscriptions so a + re-hire under the same user can take the same (owner, repo) — #31. + Audit tables (agent_memory / agent_action_log / reviewed_prs) are + intentionally untouched.""" + from app.services.orchestrator import Orchestrator + + agent = { + "id": "agent-001", + "user_id": "user-001", + "role": "code-review-engineer", + "container_id": "ctr-123", + "status": "running", + "config_json": {}, + } + agents_table = fake_supabase.get_table("agents") + agents_table.set_select_result([agent]) + agents_table.set_update_result([{**agent, "status": "stopped"}]) + mock_docker.containers.get.return_value = MagicMock() + + with patch( + "app.services.orchestrator.WatchedRepoModel.delete_by_agent" + ) as mock_delete: + orch = Orchestrator() + orch.stop_agent("agent-001") + + mock_delete.assert_called_once_with("agent-001") + class TestGetAgentStatus: def test_clears_agent_token_when_container_exited(self, fake_supabase, mock_docker):