fix(backend): critical bug fixes for agent termination and sprint validation

Bug Fixes:
- bulk_terminate_by_project now unassigns issues before terminating agents
  to prevent orphaned issue assignments
- PATCH /issues/{id} now validates sprint status - cannot assign issues
  to COMPLETED or CANCELLED sprints
- archive_project now performs cascading cleanup:
  - Terminates all active agent instances
  - Cancels all planned/active sprints
  - Unassigns issues from terminated agents

Added edge case tests for all fixed bugs (19 new tests total):
- TestBulkTerminateEdgeCases
- TestSprintStatusValidation
- TestArchiveProjectCleanup
- TestDataIntegrityEdgeCases (IDOR protection)

Coverage: 93% (1836 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 15:23:21 +01:00
parent b8265783f3
commit 06b2491c1f
4 changed files with 532 additions and 12 deletions

View File

@@ -5,13 +5,15 @@ import logging
from typing import Any
from uuid import UUID
from sqlalchemy import func, or_, select
from datetime import UTC, datetime
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.syndarix import AgentInstance, Issue, Project, Sprint
from app.models.syndarix.enums import ProjectStatus, SprintStatus
from app.models.syndarix.enums import AgentStatus, ProjectStatus, SprintStatus
from app.schemas.syndarix import ProjectCreate, ProjectUpdate
logger = logging.getLogger(__name__)
@@ -283,7 +285,13 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
*,
project_id: UUID,
) -> Project | None:
"""Archive a project by setting status to ARCHIVED."""
"""Archive a project by setting status to ARCHIVED.
This also performs cascading cleanup:
- Terminates all active agent instances
- Cancels all planned/active sprints
- Unassigns issues from terminated agents
"""
try:
result = await db.execute(
select(Project).where(Project.id == project_id)
@@ -293,9 +301,63 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
if not project:
return None
now = datetime.now(UTC)
# 1. Get all agent IDs that will be terminated
agents_to_terminate = await db.execute(
select(AgentInstance.id).where(
AgentInstance.project_id == project_id,
AgentInstance.status != AgentStatus.TERMINATED,
)
)
agent_ids = [row[0] for row in agents_to_terminate.fetchall()]
# 2. Unassign issues from these agents to prevent orphaned assignments
if agent_ids:
await db.execute(
update(Issue)
.where(Issue.assigned_agent_id.in_(agent_ids))
.values(assigned_agent_id=None)
)
# 3. Terminate all active agents
await db.execute(
update(AgentInstance)
.where(
AgentInstance.project_id == project_id,
AgentInstance.status != AgentStatus.TERMINATED,
)
.values(
status=AgentStatus.TERMINATED,
terminated_at=now,
current_task=None,
session_id=None,
updated_at=now,
)
)
# 4. Cancel all planned/active sprints
await db.execute(
update(Sprint)
.where(
Sprint.project_id == project_id,
Sprint.status.in_([SprintStatus.PLANNED, SprintStatus.ACTIVE]),
)
.values(
status=SprintStatus.CANCELLED,
updated_at=now,
)
)
# 5. Archive the project
project.status = ProjectStatus.ARCHIVED
await db.commit()
await db.refresh(project)
logger.info(
f"Archived project {project_id}: terminated agents={len(agent_ids)}"
)
return project
except Exception as e:
await db.rollback()